TCTF2022 MarketNFT
这个题目考察的点是 Head Overflow Bug in Calldata Tuple ABI-Reencoding
,是最近出现为数不多evm爆出来的漏洞。也是当时我们基本没有人想到的点。国内应该只有AAA战队的师傅做出来了。今天重新来学习下。当时WP队内发了这篇文章但是距离比赛结束时间太短了。没有来得及去调以及搞。
Source
// SPDX-License-Identifier: UNLICENSED
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));
}
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");
}
}
首先简要说说这道题实现的东西。获得flag的要求是成为创建者_mint的3个NFT的owner。
然后非常显然从airdrop可以拿5个token,直接买NFT1 即可。然后我们去挂NFT1低价 然后用purchasetest挂高价NFT1让NFTMARKET去买高价的NFT1这样咱家就有钱买NFT2了,最后再低价收NFT1.这是最基础的两个NFT漏洞点。然后NFT3是一个比较特殊的问题。他出现了一个abi encoder V2的问题。在解析显式或隐式ABI ENCODING的时候,对于动态类型数组的解析进行了两次,对一些VALUE进行了内存管理回收导致了覆盖值的问题。从而写了零值。
我们跟着官方文档来一遍:
首先编写测试合约如下:
struct T {
bytes x;
uint[3] y;
}
contract D {
bytes public a;
function f(bool a, T calldata b, bytes32[2] calldata c) public returns(bytes memory){
return (msg.data);
}
}
contract diaoyong{
D a=D(0xd8b934580fcE35a11B58C6D73aDeE468a2833fa8);
bytes public t;
constructor()public {
t=a.f(true, T("abcd", [uint(11), 12, 13]), [bytes32("a"), "b"]);
}
}
获取 msg.data如下:
9b93f0e3
0000000000000000000000000000000000000000000000000000000000000001 // a
0000000000000000000000000000000000000000000000000000000000000080 // 对于b的偏移
6100000000000000000000000000000000000000000000000000000000000000 // c[0]
6200000000000000000000000000000000000000000000000000000000000000 // c[1]
0000000000000000000000000000000000000000000000000000000000000080 // 对于b.x的偏移
000000000000000000000000000000000000000000000000000000000000000b // b.y[0]
000000000000000000000000000000000000000000000000000000000000000c // b.y[1]
000000000000000000000000000000000000000000000000000000000000000d // b.y[2]
0000000000000000000000000000000000000000000000000000000000000004 // b.x的长度
6162636400000000000000000000000000000000000000000000000000000000 // b.x
由于b.x是 bytes动态类型,这也导致了我们整个T结构体变成了动态类型。有一个偏移来指向它。
然后官方文档给了如下图:
|---------------------------------------||----------------------------------------|
| HEAD || TAIL |
|------------+-------------+------------||----------------------------------------|
| value of a | offset of b | value of c || value of b |
| bool | uint | bytes32[2] || T |
| | | ||--------------------------||------------|
| | | || HEAD of T || TAIL of T |
| | | ||-------------+------------||------------|
| | | || offset of x | value of y || value of x |
| | | || uint | uint[3] || bytes |
|------------+-------------+------------||-------------+------------||------------|
| 1 | 2 | 6 || 3 | 5 || 4 |
|------------+-------------+------------||-------------+------------||------------|
这里我做简要解释,下面的数字是理论上的编码顺序。首先是bool a然后是b的偏移,然后应该b.x的偏移,b.x 然后b.y 最后是c
但是实际上的编码是如图所示排布的。而清理机制是会在每个HEAD部分的末尾出现的。HEAD和TAIL一般是由于动态变量和静态变量的变化产生的。当c结束编码后。对于其进行清理就会清理掉b.x的偏移 而第二层嵌套中的 y结束,则会清理 b.x长度这一变量(在图中并未写)。但因为b.x是动态变量所以在数据前他写了个长度。
那么我们可以让他再次隐式编码下。看看获取到的变量值变成了什么。
struct T {
bytes x;
uint[3] y;
}
contract D {
bytes public a;
function f(bool a, T calldata b, bytes32[2] calldata c) public returns(bool,T calldata,bytes32[2] calldata){
return (a,b,c);
}
}
contract diaoyong{
D a=D(0xD7ACd2a9FD159E69Bb102A1ca21C9a3e3A5F771B);
bool public val_1;
T public val_2;
bytes32[2] public val_3;
bytes public t;
constructor()public {
(val_1,val_2,val_3)=a.f(true, T("abcd", [uint(11), 12, 13]), [bytes32("a"), "b"]);
}
}
可以发现x部分全部被清空了。
那么漏洞原理部分已经讲完了。
题目中的结构体嵌套如下:
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中有一个bytes reason是变长类型,那么导致了整个Coupon也会变成动态类型。也是同样的一个二层嵌套。我们来看一下他的abi正常格式:
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 D {
bytes public t;
function f(SignedCoupon memory a) public{
t=msg.data;
}
}
contract diaoyong{
D a=D(0x9D7f74d0C41E726EC95884E0e97Fa6129e3b5E99);
SignedCoupon public bs;
constructor()public {
bs.coupon.orderId=1;
bs.coupon.newprice=0xdeadbeef;
bs.coupon.issuer=address(this);
bs.coupon.user=address(0);
bs.coupon.reason="abcd";
bs.signature.v=27;
bs.signature.rs[0]="a";
bs.signature.rs[1]="b";
a.f(bs);
}
}
9b537c1d //selector
0000000000000000000000000000000000000000000000000000000000000020 // offset bs HEAD
0000000000000000000000000000000000000000000000000000000000000080 // offset bs.coupon HEAD
000000000000000000000000000000000000000000000000000000000000001b // bs.sig.v HEAD
6100000000000000000000000000000000000000000000000000000000000000 // bs.sig.rs[0] HEAD
6200000000000000000000000000000000000000000000000000000000000000 // bs.sig.rs[1] HEAD
0000000000000000000000000000000000000000000000000000000000000001 // bs.coupon.orderId TAIL
00000000000000000000000000000000000000000000000000000000deadbeef // bs.couon.newprice TAIL
000000000000000000000000d2a5bc10698fd955d1fe6cb468a17809a08fd005 // bs.coupon.issuer TAIL
0000000000000000000000000000000000000000000000000000000000000000 // bs.coupon.user TAIL
00000000000000000000000000000000000000000000000000000000000000a0 // 从bs开始的指向reason的偏移。 TAIL
0000000000000000000000000000000000000000000000000000000000000004 // bs.coupon.reason len TAIL TAIL
6162636400000000000000000000000000000000000000000000000000000000 //bs.coupon.reason TAIL TAIL
这中间有个a0我并没有理解是用来标注什么的。如果有大哥可以教学一下的话就最好了。不过我们可以看出来这里orderID作为第一个TAIL的头部会被清理成0. 那么攻击链就有了。
- 由于他的cancelOrder是把最后一个放到当前删除的上面。我们可以先创建一个假的Order 让他里面做一次虚假的NFT签名。
- 然后再去买NFT1和通过purchaseTest后门打NFT2。这个时候 0就是我们签的假的NFT,1是题目签的NFT3.
- 这个时候去purchasewithCoupon。用的orderID走1.但是验证的时候orderID已经是0了。通过自己签名伪造后就可以把orderID=1的转出来了。从而获得最后的NFT3.
需要注意的小细节就是 伪造NFT的时候需要写几个接口 比如ownerof 以及approveset什么什么的。
POC
https://hitcxy.com/2022/0ctf2022/
因为里面涉及的签名部分需要coupon协商价格时的issuer为合约,然后才能用私钥去算rsv。在remix上就比较麻烦了。这里可以看皮神的私链一键poc。
致谢
Ver、s3cunDa、pikachu。