0ctf-2022-NFTMarket

Posted by Thomas_Xu on 2023-03-07

0ctf-2022-NFTMarket


author:Thomas_Xu

此题搬运自iczc大佬的blog,这个题的关键点在于solidity 8.16版本之前的bug。我觉得很有意思,于是在这里记录一下。

而其实这个漏洞在solidity的官方blog中提到过,这给我们一个启示就是,要随时关注官方的blog。

题目合约

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
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
pragma solidity 0.8.15;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";


contract TctfNFT is ERC721, Ownable {
constructor() ERC721("TctfNFT", "TNFT") {
_setApprovalForAll(address(this), msg.sender, true);
}

function mint(address to, uint256 tokenId) external onlyOwner {
_mint(to, tokenId);
}
}

contract TctfToken is ERC20 {
bool airdropped;

constructor() ERC20("TctfToken", "TTK") {
_mint(address(this), 100000000000);
_mint(msg.sender, 1337);
}

function airdrop() external {
require(!airdropped, "Already airdropped");
airdropped = true;
_mint(msg.sender, 5);
}
}

struct Order {
address nftAddress;
uint256 tokenId;
uint256 price;
}
struct Coupon {
uint256 orderId;
uint256 newprice;
address issuer;
address user;
bytes reason;
}
struct Signature {
uint8 v;
bytes32[2] rs;
}
struct SignedCoupon {
Coupon coupon;
Signature signature;
}

contract TctfMarket {
event SendFlag();
event NFTListed(
address indexed seller,
address indexed nftAddress,
uint256 indexed tokenId,
uint256 price
);

event NFTCanceled(
address indexed seller,
address indexed nftAddress,
uint256 indexed tokenId
);

event NFTBought(
address indexed buyer,
address indexed nftAddress,
uint256 indexed tokenId,
uint256 price
);

bool tested;
TctfNFT public tctfNFT;
TctfToken public tctfToken;
CouponVerifierBeta public verifier;
Order[] orders;

constructor() {
tctfToken = new TctfToken();
tctfToken.approve(address(this), type(uint256).max);

tctfNFT = new TctfNFT();
tctfNFT.mint(address(tctfNFT), 1);
tctfNFT.mint(address(this), 2);
tctfNFT.mint(address(this), 3);

verifier = new CouponVerifierBeta();

orders.push(Order(address(tctfNFT), 1, 1));
orders.push(Order(address(tctfNFT), 2, 1337));
orders.push(Order(address(tctfNFT), 3, 13333333337));
// 100000000000
}

function getOrder(uint256 orderId) public view returns (Order memory order) {
require(orderId < orders.length, "Invalid orderId");
order = orders[orderId];
}

function createOrder(address nftAddress, uint256 tokenId, uint256 price) external returns(uint256) {
require(price > 0, "Invalid price");
require(isNFTApprovedOrOwner(nftAddress, msg.sender, tokenId), "Not owner");
orders.push(Order(nftAddress, tokenId, price));
emit NFTListed(msg.sender, nftAddress, tokenId, price);
return orders.length - 1;
}

function cancelOrder(uint256 orderId) external {
Order memory order = getOrder(orderId);
require(isNFTApprovedOrOwner(order.nftAddress, msg.sender, order.tokenId), "Not owner");
_deleteOrder(orderId);
emit NFTCanceled(msg.sender, order.nftAddress, order.tokenId);
}

function purchaseOrder(uint256 orderId) external {
Order memory order = getOrder(orderId);
_deleteOrder(orderId);
IERC721 nft = IERC721(order.nftAddress);
address owner = nft.ownerOf(order.tokenId);
tctfToken.transferFrom(msg.sender, owner, order.price);
nft.safeTransferFrom(owner, msg.sender, order.tokenId);
emit NFTBought(msg.sender, order.nftAddress, order.tokenId, order.price);
}

function purchaseWithCoupon(SignedCoupon calldata scoupon) external {
Coupon memory coupon = scoupon.coupon;
require(coupon.user == msg.sender, "Invalid user");
require(coupon.newprice > 0, "Invalid price");
verifier.verifyCoupon(scoupon);
Order memory order = getOrder(coupon.orderId);
_deleteOrder(coupon.orderId);
IERC721 nft = IERC721(order.nftAddress);
address owner = nft.ownerOf(order.tokenId);
tctfToken.transferFrom(coupon.user, owner, coupon.newprice);
nft.safeTransferFrom(owner, coupon.user, order.tokenId);
emit NFTBought(coupon.user, order.nftAddress, order.tokenId, coupon.newprice);
}

function purchaseTest(address nftAddress, uint256 tokenId, uint256 price) external {
require(!tested, "Tested");
tested = true;
IERC721 nft = IERC721(nftAddress);
uint256 orderId = TctfMarket(this).createOrder(nftAddress, tokenId, price);
nft.approve(address(this), tokenId);
TctfMarket(this).purchaseOrder(orderId);
}

function win() external {
require(tctfNFT.ownerOf(1) == msg.sender && tctfNFT.ownerOf(2) == msg.sender && tctfNFT.ownerOf(3) == msg.sender);
emit SendFlag();
}

function isNFTApprovedOrOwner(address nftAddress, address spender, uint256 tokenId) internal view returns (bool) {
IERC721 nft = IERC721(nftAddress);
address owner = nft.ownerOf(tokenId);
return (spender == owner || nft.isApprovedForAll(owner, spender) || nft.getApproved(tokenId) == spender);
}

function _deleteOrder(uint256 orderId) internal {
orders[orderId] = orders[orders.length - 1];
orders.pop();
}

function onERC721Received(address, address, uint256, bytes memory) public pure returns (bytes4) {
return this.onERC721Received.selector;
}
}

contract CouponVerifierBeta {
TctfMarket market;
bool tested;

constructor() {
market = TctfMarket(msg.sender);
}

function verifyCoupon(SignedCoupon calldata scoupon) public {
require(!tested, "Tested");
tested = true;
Coupon memory coupon = scoupon.coupon;
Signature memory sig = scoupon.signature;
Order memory order = market.getOrder(coupon.orderId);
bytes memory serialized = abi.encode(
"I, the issuer", coupon.issuer,
"offer a special discount for", coupon.user,
"to buy", order, "at", coupon.newprice,
"because", coupon.reason
);
IERC721 nft = IERC721(order.nftAddress);
address owner = nft.ownerOf(order.tokenId);
require(coupon.issuer == owner, "Invalid issuer");
require(ecrecover(keccak256(serialized), sig.v, sig.rs[0], sig.rs[1]) == coupon.issuer, "Invalid signature");
}

}

分析

题目逻辑还是很简单的,实现了一个简易版本的 nft market。

完成题目需要获得 1, 2, 3 号nft,这些 nft 是属于题目合约的(1属于 nft 合约本身,不过不影响),并且在最开始就被放入了市场中,价格分别为1,1337,133333333337.

初始状态选手只能获得5个token空投,market拥有1337个token。

常理来说玩家只能购买1号nft,剩下的两个太贵了买不起。

purchaseTest 属于一个后门,其逻辑如下:

1
2
3
4
5
6
7
8
function purchaseTest(address nftAddress, uint256 tokenId, uint256 price) external {
require(!tested, "Tested");
tested = true;
IERC721 nft = IERC721(nftAddress);
uint256 orderId = TctfMarket(this).createOrder(nftAddress, tokenId, price);
nft.approve(address(this), tokenId);
TctfMarket(this).purchaseOrder(orderId);
}

这个函数可以让market本身进行一个新的order的构造,然后让market自己再把这个order买下来。

但是问题是这个函数没有制定nft的地址,所以完全可以自己构造一个fakenft,让market买下来,这样可以最多获得1337个token,这样2号就解决了。

那么1和2搞定了,如何搞定3呢?

尝试

想拿出来3,有三种方式:

  1. 搞出来一堆erc20,但是题目合约最多就1337个,token合约虽然给自己mint了一堆,但是没有其他操作,所以不可行。
  2. 改价格:1. 在交易过程中改 2. 通过coupon
  3. 直接给转出来,没看到有能利用的点

其中1和3都是不可行的,只有2是可能的。

如何改价格

对于在交易过程中改价格,想法是通过可控的外部调用进行重入,改变order数组的结构。

对于改价格的点,有如下几个:

  1. safetransferFrom:没用,每次调用都是在交易末尾,重入没有意义。
  2. purchaseTest的approve:可以构造一个假的nft,重写approve逻辑进行重入,但是问题是,我们的目的是改变3的价格,对于3的order,只有其owner可以创建,那么唯一的机会就是在test里面,那么nft的地址就必须是题目的地址,那么久没法改approve进行重入。如果上来就给假的nft地址,那么这一切都毫无意义,不可行。
  3. verifyCoupon的ownerOf:由于purchaseWithCoupon 函数调用 verify 前后没有对conpon的order进行一致性校验,那么按理说我们就可以通过在verifyCoupon函数中做一些操作改变order数组结构,也就是使得签名验证和后续的购买出现偏差,使得可以低价购入3号nft。(正确思路确实要用到这里,但是并不是上述的思路)

对于3号思路,本来想的是没问题的,但是在后面才想起来ownerOf是一个view函数,底层用的是staticcall,不能做状态改变,所以这条路也行不通。

正解

这是一个8.15以及之前版本出现的问题,详细解释见这篇文章:https://blog.soliditylang.org/2022/08/08/calldata-tuple-reencoding-head-overflow-bug/

通俗来说,就是如果一个结构体中间有一个变长的结构,比如string或者bytes,那么他在第二次打包的时候会出现bug,导致结构体的第一个字段被改成0.

题目中的结构体为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct Order {
address nftAddress;
uint256 tokenId;
uint256 price;
}
struct Coupon {
uint256 orderId;
uint256 newprice;
address issuer;
address user;
bytes reason;
}
struct Signature {
uint8 v;
bytes32[2] rs;
}
struct SignedCoupon {
Coupon coupon;
Signature signature;
}

其中SignedCoupon就是一个满足条件的可以触发bug的结构体,以为他中间的字段reason是个变长字段,第二次打包calldata,也就是传入verify的时候,他的第一个字段也就是orderId就成0了。

而题目合约没有创tokenid为0的NFT,这就给了我们钻空子的机会,我们只需要mint一个tokenid为0的NFT,并把价格设为0就可以免费购入所有NFT。

测试代码:

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
pragma solidity  0.8.15;
struct Order {
address nftAddress;
uint256 tokenId;
uint256 price;
}
struct Coupon {
uint256 orderId;
uint256 newprice;
address issuer;
address user;
bytes reason;
}
struct Signature {
uint8 v;
bytes32[2] rs;
}
struct SignedCoupon {
Coupon coupon;
Signature signature;
}
contract Verifier{
address public issuer;
address public recovered;
Coupon public c;
function verifyCoupon(SignedCoupon calldata scoupon) public {

Coupon memory coupon = scoupon.coupon;
Signature memory sig = scoupon.signature;
c=coupon;
Order memory order;
order.nftAddress = address(0);
order.tokenId = 0xdeadbeef;
order.price = 0xcafebabe;
bytes memory serialized = abi.encode(
"I, the issuer", coupon.issuer,
"offer a special discount for", coupon.user,
"to buy", order, "at", coupon.newprice,
"because", coupon.reason
);
recovered = ecrecover(keccak256(serialized), sig.v, sig.rs[0], sig.rs[1]);
issuer = coupon.issuer;

}
}

contract caller{
Verifier public verifier;
Coupon public cp;
constructor(address v){
verifier = Verifier(v);
}
function purchaseWithCoupon(SignedCoupon calldata scoupon) public {
Coupon memory coupon = scoupon.coupon;
require(coupon.user == msg.sender, "Invalid user");
require(coupon.newprice > 0, "Invalid price");
verifier.verifyCoupon(scoupon);
cp = coupon;

}
function test() public{
Coupon memory c;
c.orderId = 0xdeadbeef;
c.newprice = 1;
c.issuer = address(0x123456);
c.user = address(this);
c.reason = 'lalalalalaallalalalaalallalalalalalalalaalaalalala';
SignedCoupon memory scoupon;
scoupon.coupon = c;

Signature memory sig;
sig.v = 17;
sig.rs[1] = bytes32(0);
sig.rs[0] = bytes32(0);

scoupon.signature = sig;
caller(this).purchaseWithCoupon(scoupon);
}
}

直接调用test,在remix上测试结果如下:

image-20220919130423963

可以看到,orderId确实是被改成0了。


notice

true

This is copyright.

...

...

00:00
00:00