默认分类

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. 那么攻击链就有了。

  1. 由于他的cancelOrder是把最后一个放到当前删除的上面。我们可以先创建一个假的Order 让他里面做一次虚假的NFT签名。
  2. 然后再去买NFT1和通过purchaseTest后门打NFT2。这个时候 0就是我们签的假的NFT,1是题目签的NFT3.
  3. 这个时候去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。

回复

This is just a placeholder img.