Repository navigation
Expand file tree
/
Copy pathexploit.compact
More file actions
203 lines (169 loc) · 7.26 KB
/
Copy pathexploit.compact
File metadata and controls
203 lines (169 loc) · 7.26 KB
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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
pragma language_version 0.26;
import CompactStandardLibrary;
// Proof of Exploit
//
// A researcher proves they hold an input that breaks a committed target,
// locks the bounty to themselves, and is paid only at the moment they
// reveal the input. The exploit stays hidden between those two steps.
//
// The target modelled here is a vault with a deliberate bug:
//
// V1 (buggy) withdrawal allowed when amount <= poolBalance
// V2 (fixed) withdrawal allowed when amount <= entitlement
//
// invariant a withdrawal must never exceed the caller's entitlement
//
// Under V1 a caller can withdraw far more than they are entitled to.
// Under V2 the same input cannot satisfy both conditions, so the proof
// fails. That is the closing beat of the demo.
export enum Stage { posted, locked, settled }
// ---------------------------------------------------------------
// public ledger state
// ---------------------------------------------------------------
export ledger stage: Stage;
// Which version of the target this bounty is posted against.
export ledger targetVersion: Uint<8>;
// A commitment to the exact target being attacked, and to the property
// being claimed broken. Without these the proof is bound to the number
// "1" rather than to any real contract, and a claim from one bounty
// could be replayed against another.
export ledger targetHash: Bytes<32>;
export ledger invariantId: Bytes<32>;
// The target's public parameters. Public because they are on-chain
// state of the contract under test, not part of the exploit.
export ledger poolBalance: Uint<64>;
export ledger entitlement: Uint<64>;
// What the vendor is paying, and who has locked it.
export ledger bounty: Uint<64>;
export ledger vendor: Bytes<32>;
export ledger claimant: Bytes<32>;
// Binds the locked bounty to one specific exploit input.
// C = persistentHash(["poe:commit:", salt, H(amount)])
export ledger commitment: Bytes<32>;
// A lower bound on the damage, disclosed deliberately so the vendor can
// triage severity without learning the input.
export ledger minImpact: Uint<64>;
// The bounty is held by this contract in unshielded NIGHT until
// settlement. Unshielded because that is what wallets actually hold.
export ledger funded: Boolean;
export ledger paid: Boolean;
// A vendor must be able to get an unclaimed reward back, otherwise a
// bounty nobody solves locks the money forever.
export ledger refunded: Boolean;
export ledger vendorAddress: UserAddress;
// Where the bounty goes when the researcher settles. Recorded at claim
// time so the payout is bound to whoever proved the exploit.
export ledger payoutAddress: UserAddress;
// ---------------------------------------------------------------
// witnesses: the exploit and its blinding, held only by the researcher
// ---------------------------------------------------------------
witness localSecretKey(): Bytes<32>;
witness exploitAmount(): Uint<64>;
witness exploitSalt(): Bytes<32>;
// ---------------------------------------------------------------
// helpers
// ---------------------------------------------------------------
export circuit publicKey(sk: Bytes<32>): Bytes<32> {
return persistentHash<Vector<2, Bytes<32>>>([pad(32, "poe:pk:"), sk]);
}
// The commitment is scoped to the target and the invariant, so a claim
// proved against one bounty cannot be settled against another.
export circuit commit(
amount: Uint<64>,
salt: Bytes<32>,
target: Bytes<32>,
invariant: Bytes<32>,
): Bytes<32> {
return persistentHash<Vector<5, Bytes<32>>>(
[pad(32, "poe:commit:"), salt, persistentHash<Uint<64>>(amount),
target, invariant]);
}
// ---------------------------------------------------------------
// circuits
// ---------------------------------------------------------------
export circuit postBounty(
version: Uint<8>,
pool: Uint<64>,
entitled: Uint<64>,
reward: Uint<64>,
target: Bytes<32>,
invariant: Bytes<32>,
refundTo: UserAddress,
): [] {
assert(stage == Stage.posted, "bounty already posted");
assert(reward > 0, "bounty must be positive");
assert(!funded, "bounty already funded");
// Take custody of the reward. Until this lands there is nothing to win.
receiveUnshielded(nativeToken(), disclose(reward) as Uint<128>);
funded = true;
targetVersion = disclose(version);
targetHash = disclose(target);
invariantId = disclose(invariant);
poolBalance = disclose(pool);
entitlement = disclose(entitled);
bounty = disclose(reward);
vendor = disclose(publicKey(localSecretKey()));
vendorAddress = disclose(refundTo);
}
// The claim.
//
// Proves that the researcher holds an amount which the target ACCEPTS
// and which VIOLATES the invariant, and binds that exact amount to a
// commitment. Nothing about the amount reaches the chain except a lower
// bound the researcher chooses to assert.
export circuit proveExploit(claimedImpact: Uint<64>, payTo: UserAddress): [] {
assert(stage == Stage.posted, "bounty is not open");
assert(funded, "bounty is not funded");
const amount = exploitAmount();
// 1. The target actually accepts this transition.
// This is the version-specific rule, and it is why a patched
// target cannot be exploited by the same input.
if (targetVersion == 1) {
// V1, the bug: the wrong balance is checked.
assert(disclose(amount <= poolBalance), "target rejects this input");
} else {
// V2, patched.
assert(disclose(amount <= entitlement), "target rejects this input");
}
// 2. The resulting state violates the invariant.
assert(disclose(amount > entitlement), "no invariant violation");
// 3. The impact is at least what the researcher claims.
assert(disclose(amount >= claimedImpact), "impact overstated");
// 4. Bind the bounty to this exact input.
commitment = disclose(commit(amount, exploitSalt(), targetHash, invariantId));
claimant = disclose(publicKey(localSecretKey()));
payoutAddress = disclose(payTo);
minImpact = disclose(claimedImpact);
stage = Stage.locked;
}
// Payment.
//
// The researcher must reveal the input that step 2 already proved to be
// exploiting. Revealing anything else fails the hash check, so there is
// no way to be paid without disclosing the real exploit.
export circuit settle(amount: Uint<64>, salt: Bytes<32>): [] {
assert(stage == Stage.locked, "nothing is locked");
assert(publicKey(localSecretKey()) == claimant, "not the claimant");
assert(commit(disclose(amount), disclose(salt), targetHash, invariantId)
== commitment,
"reveal does not match the committed exploit");
// The money moves in the same transition that reveals the exploit.
sendUnshielded(nativeToken(), bounty as Uint<128>,
right<ContractAddress, UserAddress>(payoutAddress));
paid = true;
stage = Stage.settled;
}
// Withdraw an unclaimed reward.
//
// Only the vendor, and only while no researcher holds a claim. Once a
// claim is locked the money belongs to that researcher's settlement.
export circuit cancelBounty(): [] {
assert(stage == Stage.posted, "a claim is already locked, or the bounty is settled");
assert(funded, "nothing is escrowed");
assert(publicKey(localSecretKey()) == vendor, "only the vendor can cancel");
sendUnshielded(nativeToken(), bounty as Uint<128>,
right<ContractAddress, UserAddress>(vendorAddress));
funded = false;
refunded = true;
stage = Stage.settled;
}