Skip to content

Commit d03671e

Browse files
victorgesyondonfu
andauthored
Merge commit from fork
* src/test: Add PoC for fee overclaim, copied from report Only adjusted some things to work with our own local forge * src/test: Update PoC to ensure criticality - Redeem a real ticket - Withdraw all Minter balance - Update to match existing tests code patterns src/test: Make the PoC redeem a real ticket src/test: Make the PoC redeem a real ticket src/test: Update PoC to run on latest block Steals all of the 189 ETH from the minter Address review comments * bonding: Active check in updateTranscoderWithFees * src/test: Add test to prove the patch solves the issue * src/test: Ensure fix works for activating and reactivated Ts * deployments: Include fixed contract deployment artifacts * src/test: Update fix test to use deployed contract * Revert "src/test: Update fix test to use deployed contract" This reverts commit b47b1c5e313ae38f72a99ef1e73027645ab6c5f7. --------- Co-authored-by: Yondon Fu <[email protected]>
1 parent e8b6243 commit d03671e

File tree

7 files changed

+1196
-24
lines changed

7 files changed

+1196
-24
lines changed

contracts/bonding/BondingManager.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ contract BondingManager is ManagerProxyTarget, IBondingManager {
307307
// Silence unused param compiler warning
308308
_round;
309309

310-
require(isRegisteredTranscoder(_transcoder), "transcoder must be registered");
310+
require(isActiveTranscoder(_transcoder), "transcoder must be active");
311311

312312
uint256 currentRound = roundsManager().currentRound();
313313

deployments/arbitrumMainnet/BondingManagerTarget.json

Lines changed: 15 additions & 15 deletions
Large diffs are not rendered by default.

deployments/arbitrumMainnet/solcInputs/68b0b6154a921bdff5991504c6ea9303.json

Lines changed: 494 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
1+
// SPDX-License-Identifier: UNLICENSED
2+
pragma solidity ^0.8.9;
3+
4+
import "ds-test/test.sol";
5+
import "./base/GovernorBaseTest.sol";
6+
import "forge-std/console.sol";
7+
8+
import "contracts/bonding/BondingManager.sol";
9+
import "contracts/rounds/RoundsManager.sol";
10+
import "contracts/pm/TicketBroker.sol";
11+
import "contracts/token/LivepeerToken.sol";
12+
13+
struct DelegatorData {
14+
uint256 bondedAmount;
15+
uint256 fees;
16+
address delegateAddress;
17+
uint256 delegatedAmount;
18+
uint256 startRound;
19+
uint256 lastClaimRound;
20+
uint256 nextUnbondingLockId;
21+
}
22+
23+
struct TranscoderEarningsPoolData {
24+
uint256 totalStake;
25+
uint256 transcoderRewardCut;
26+
uint256 transcoderFeeShare;
27+
uint256 cumulativeRewardFactor;
28+
uint256 cumulativeFeeFactor;
29+
}
30+
31+
struct TranscoderData {
32+
uint256 lastRewardRound;
33+
uint256 rewardCut;
34+
uint256 feeShare;
35+
uint256 lastActiveStakeUpdateRound;
36+
uint256 activationRound;
37+
uint256 deactivationRound;
38+
uint256 activeCumulativeRewards;
39+
uint256 cumulativeRewards;
40+
uint256 cumulativeFees;
41+
uint256 lastFeeRound;
42+
}
43+
44+
interface IBondingManagerHelper {
45+
function getTranscoderEarningsPoolForRound(address _transcoder, uint256 _round)
46+
external
47+
view
48+
returns (TranscoderEarningsPoolData memory);
49+
50+
function getTranscoder(address _transcoder) external view returns (TranscoderData memory);
51+
52+
function getDelegator(address _delegator) external view returns (DelegatorData memory);
53+
}
54+
55+
interface IRoundManager {
56+
function roundLength() external returns (uint256);
57+
58+
function currentRound() external view returns (uint256);
59+
60+
function initializeRound() external;
61+
}
62+
63+
// forge test --match-contract BondingManagerFeeOverclaimFix --fork-url https://arbitrum-mainnet.infura.io/v3/$INFURA_KEY -vvv --fork-block-number 371152514
64+
contract BondingManagerFeeOverclaimFix is GovernorBaseTest {
65+
LivepeerToken public constant TOKEN = LivepeerToken(0x289ba1701C2F088cf0faf8B3705246331cB8A839);
66+
IMinter public constant MINTER = IMinter(0xc20DE37170B45774e6CD3d2304017fc962f27252);
67+
BondingManager public constant BONDING_MANAGER = BondingManager(0x35Bcf3c30594191d53231E4FF333E8A770453e40);
68+
RoundsManager public constant ROUNDS_MANAGER = RoundsManager(0xdd6f56DcC28D3F5f27084381fE8Df634985cc39f);
69+
TicketBroker public constant TICKET_BROKER = TicketBroker(0xa8bB618B1520E284046F3dFc448851A1Ff26e41B);
70+
71+
uint256 public constant ATTACKER_KEY = 3171;
72+
uint256 public constant TICKET_SENDER_KEY = 31337;
73+
74+
uint256 public constant ATTACK_GRANULARITY = 1000 wei;
75+
76+
uint256 roundLength;
77+
address ticketSender;
78+
79+
bytes32 public constant BONDING_MANAGER_TARGET_ID = keccak256("BondingManagerTarget");
80+
BondingManager public newBondingManagerTarget;
81+
82+
address attacker;
83+
address someone;
84+
85+
function setUp() public {
86+
CHEATS.roll(23198137);
87+
88+
newBondingManagerTarget = new BondingManager(address(CONTROLLER));
89+
(, gitCommitHash) = CONTROLLER.getContractInfo(BONDING_MANAGER_TARGET_ID);
90+
stageAndExecuteOne(
91+
address(CONTROLLER),
92+
0,
93+
abi.encodeWithSelector(
94+
CONTROLLER.setContractInfo.selector,
95+
BONDING_MANAGER_TARGET_ID,
96+
address(newBondingManagerTarget),
97+
gitCommitHash
98+
)
99+
);
100+
101+
attacker = CHEATS.addr(ATTACKER_KEY);
102+
someone = newAddr();
103+
ticketSender = CHEATS.addr(TICKET_SENDER_KEY);
104+
roundLength = ROUNDS_MANAGER.roundLength();
105+
106+
// Fund the ticket sender on ticket broker
107+
payable(ticketSender).transfer(1 ether);
108+
CHEATS.prank(ticketSender);
109+
TICKET_BROKER.fundDeposit{ value: 1 ether }();
110+
111+
/// attacker need 2000 lpt to execute the attack
112+
CHEATS.startPrank(address(MINTER));
113+
TOKEN.mint(attacker, 4000 * 1e18 + 2);
114+
TOKEN.mint(someone, 2000 * 1e18 + 2);
115+
CHEATS.stopPrank();
116+
117+
/// ---------------------- ROUND = 3902 ----------------------
118+
_skipToNextRound(3902);
119+
120+
/// hacker bond for themself to make their status in the next round become "Bonded"
121+
CHEATS.startPrank(attacker);
122+
TOKEN.approve(address(BONDING_MANAGER), type(uint256).max);
123+
BONDING_MANAGER.bond(1, attacker);
124+
BONDING_MANAGER.transcoder(1000000, 0);
125+
CHEATS.stopPrank();
126+
127+
CHEATS.startPrank(someone);
128+
TOKEN.approve(address(BONDING_MANAGER), type(uint256).max);
129+
BONDING_MANAGER.bond(1, someone);
130+
BONDING_MANAGER.transcoder(1000000, 1000000);
131+
CHEATS.stopPrank();
132+
133+
/// ---------------------- ROUND = 3903 ----------------------
134+
_skipToNextRound(3903);
135+
136+
// attacker bond more than the last transcoder and kick them out of the `transcoderPool`
137+
CHEATS.startPrank(attacker);
138+
BONDING_MANAGER.bond(1200 * 1e18, attacker);
139+
assertEq(attacker, _getLastTranscoder());
140+
CHEATS.stopPrank();
141+
142+
// unbond all but 1 "wei" of LPT to inflate rewards to the maximum
143+
CHEATS.startPrank(attacker);
144+
BONDING_MANAGER.unbond(1200 * 1e18);
145+
assertEq(_getDelegatorData(attacker).delegatedAmount, 1);
146+
assertEq(attacker, _getLastTranscoder());
147+
CHEATS.stopPrank();
148+
149+
/// ---------------------- ROUND = 3904 ----------------------
150+
_skipToNextRound(3904);
151+
152+
// someone bond more than the attacker to kick attacker out of the `transcoderPool`
153+
CHEATS.startPrank(someone);
154+
BONDING_MANAGER.bond(1200 * 1e18, someone);
155+
assertEq(someone, _getLastTranscoder());
156+
CHEATS.stopPrank();
157+
158+
/// ---------------------- ROUND = 3905 ----------------------
159+
_skipToNextRound(3905);
160+
161+
// attacker is inactive in this round
162+
assertTrue(!BONDING_MANAGER.isActiveTranscoder(attacker));
163+
164+
uint256 inflateAmount = address(MINTER).balance / ATTACK_GRANULARITY;
165+
166+
// attacker bond more tokens to themselves, but the lastActiveStakeUpdateRound remain unchange (3904)
167+
CHEATS.startPrank(attacker);
168+
BONDING_MANAGER.bond(inflateAmount - 1, attacker);
169+
CHEATS.stopPrank();
170+
171+
assertEq(someone, _getLastTranscoder());
172+
assertEq(_getTranscoderData(attacker).lastActiveStakeUpdateRound, 3904);
173+
174+
// the delegated amount is bigger than the lastActiveStakeUpdateRound's total stake now
175+
assertGt(_getDelegatorData(attacker).delegatedAmount, _getTransoderEarningPoolData(attacker, 3641).totalStake);
176+
}
177+
178+
function test_feeOverclaimFix() public {
179+
/// ---------------------- ROUND = 3906 ----------------------
180+
_skipToNextRound(3906);
181+
182+
uint256 prevFees = _getDelegatorData(attacker).fees;
183+
assertEq(prevFees, 0);
184+
185+
uint256 ticketAmount = ATTACK_GRANULARITY;
186+
187+
// Sign the winning ticket to the attacker
188+
(MTicketBrokerCore.Ticket memory ticket, bytes memory signature, uint256 rand) = signWinningTicket(
189+
ticketSender,
190+
attacker,
191+
ticketAmount
192+
);
193+
194+
uint256 prevMinterBalance = address(MINTER).balance;
195+
196+
CHEATS.startPrank(address(attacker));
197+
198+
CHEATS.expectRevert("transcoder must be active");
199+
TICKET_BROKER.redeemWinningTicket(ticket, signature, rand);
200+
CHEATS.stopPrank();
201+
202+
uint256 receivedFees = _getDelegatorData(attacker).fees;
203+
console.log("received fees =", receivedFees);
204+
assertEq(receivedFees, 0);
205+
206+
uint256 minterBalance = address(MINTER).balance;
207+
console.log("starting minter balance=", prevMinterBalance, "wei");
208+
console.log("remaining minter balance=", minterBalance, "wei");
209+
assertEq(prevMinterBalance, minterBalance);
210+
}
211+
212+
function test_feeOverclaimFix_activating_transcoder() public {
213+
/// ---------------------- ROUND = 3906 ----------------------
214+
_skipToNextRound(3906);
215+
216+
// Become active again
217+
CHEATS.startPrank(attacker);
218+
BONDING_MANAGER.bond(1500 * 1e18, attacker);
219+
CHEATS.stopPrank();
220+
221+
assertEq(attacker, _getLastTranscoder());
222+
assertTrue(!BONDING_MANAGER.isActiveTranscoder(attacker));
223+
224+
uint256 prevFees = _getDelegatorData(attacker).fees;
225+
assertEq(prevFees, 0);
226+
227+
uint256 ticketAmount = ATTACK_GRANULARITY;
228+
229+
// Sign the winning ticket to the attacker
230+
(MTicketBrokerCore.Ticket memory ticket, bytes memory signature, uint256 rand) = signWinningTicket(
231+
ticketSender,
232+
attacker,
233+
ticketAmount
234+
);
235+
236+
CHEATS.startPrank(address(attacker));
237+
238+
CHEATS.expectRevert("transcoder must be active");
239+
TICKET_BROKER.redeemWinningTicket(ticket, signature, rand);
240+
CHEATS.stopPrank();
241+
242+
uint256 receivedFees = _getDelegatorData(attacker).fees;
243+
console.log("received fees =", receivedFees);
244+
assertEq(receivedFees, 0);
245+
}
246+
247+
function test_feeOverclaimFix_reactivated_transcoder() public {
248+
/// ---------------------- ROUND = 3906 ----------------------
249+
_skipToNextRound(3906);
250+
251+
// Become active again
252+
CHEATS.startPrank(attacker);
253+
BONDING_MANAGER.bond(1500 * 1e18, attacker);
254+
CHEATS.stopPrank();
255+
256+
assertEq(attacker, _getLastTranscoder());
257+
assertTrue(!BONDING_MANAGER.isActiveTranscoder(attacker));
258+
259+
/// ---------------------- ROUND = 3907 ----------------------
260+
_skipToNextRound(3907);
261+
assertTrue(BONDING_MANAGER.isActiveTranscoder(attacker));
262+
263+
uint256 prevFees = _getDelegatorData(attacker).fees;
264+
assertEq(prevFees, 0);
265+
266+
uint256 ticketAmount = ATTACK_GRANULARITY;
267+
268+
// Sign the winning ticket to the attacker
269+
(MTicketBrokerCore.Ticket memory ticket, bytes memory signature, uint256 rand) = signWinningTicket(
270+
ticketSender,
271+
attacker,
272+
ticketAmount
273+
);
274+
275+
uint256 prevMinterBalance = address(MINTER).balance;
276+
277+
CHEATS.startPrank(address(attacker));
278+
279+
TICKET_BROKER.redeemWinningTicket(ticket, signature, rand);
280+
BONDING_MANAGER.claimEarnings(ROUNDS_MANAGER.currentRound());
281+
uint256 fees = _getDelegatorData(attacker).fees;
282+
uint256 receivedFees = fees - prevFees;
283+
BONDING_MANAGER.withdrawFees(payable(attacker), receivedFees);
284+
285+
CHEATS.stopPrank();
286+
287+
// Ticket redemption works in this case, but the value should be exactly the ticket amount
288+
console.log("received fees =", receivedFees);
289+
assertEq(receivedFees, ticketAmount);
290+
291+
uint256 minterBalance = address(MINTER).balance;
292+
console.log("starting minter balance=", prevMinterBalance, "wei");
293+
console.log("remaining minter balance=", minterBalance, "wei");
294+
assertEq(prevMinterBalance - minterBalance, ticketAmount);
295+
}
296+
297+
receive() external payable {}
298+
299+
function signWinningTicket(
300+
address sender,
301+
address recipient,
302+
uint256 faceValue
303+
)
304+
public
305+
returns (
306+
MTicketBrokerCore.Ticket memory ticket,
307+
bytes memory sig,
308+
uint256 rand
309+
)
310+
{
311+
// Prepare a always-winning ticket of 1 ETH to the main attacker contract
312+
ticket = MTicketBrokerCore.Ticket({
313+
recipient: recipient,
314+
sender: sender,
315+
faceValue: faceValue,
316+
winProb: type(uint256).max,
317+
senderNonce: 1,
318+
recipientRandHash: keccak256(abi.encodePacked(uint256(1337))),
319+
auxData: abi.encodePacked(
320+
ROUNDS_MANAGER.currentRound(),
321+
ROUNDS_MANAGER.blockHashForRound(ROUNDS_MANAGER.currentRound())
322+
)
323+
});
324+
325+
// Sign it
326+
bytes32 ticketHash = keccak256(
327+
abi.encodePacked(
328+
ticket.recipient,
329+
ticket.sender,
330+
ticket.faceValue,
331+
ticket.winProb,
332+
ticket.senderNonce,
333+
ticket.recipientRandHash,
334+
ticket.auxData
335+
)
336+
);
337+
bytes32 signHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", ticketHash));
338+
(uint8 v, bytes32 r, bytes32 s) = CHEATS.sign(TICKET_SENDER_KEY, signHash);
339+
340+
return (ticket, abi.encodePacked(r, s, v), 1337);
341+
}
342+
343+
/// ------------------ HELPER FUNCTION ------------------
344+
345+
function _skipToNextRound(uint256 expectedRound) internal {
346+
CHEATS.roll(block.number + roundLength);
347+
ROUNDS_MANAGER.initializeRound();
348+
349+
uint256 currentRound = ROUNDS_MANAGER.currentRound();
350+
assertEq(currentRound, expectedRound);
351+
console.log("\n---------------------- ROUND = %s ----------------------", currentRound);
352+
}
353+
354+
function _getDelegatorData(address del) internal view returns (DelegatorData memory) {
355+
return IBondingManagerHelper(address(BONDING_MANAGER)).getDelegator(del);
356+
}
357+
358+
function _getTransoderEarningPoolData(address del, uint256 round)
359+
internal
360+
view
361+
returns (TranscoderEarningsPoolData memory)
362+
{
363+
return IBondingManagerHelper(address(BONDING_MANAGER)).getTranscoderEarningsPoolForRound(del, round);
364+
}
365+
366+
function _getTranscoderData(address del) internal view returns (TranscoderData memory) {
367+
return IBondingManagerHelper(address(BONDING_MANAGER)).getTranscoder(del);
368+
}
369+
370+
function _getTranscoderAtIndex(uint256 index) internal view returns (address lastTranscoder) {
371+
lastTranscoder = BONDING_MANAGER.getFirstTranscoderInPool();
372+
for (uint256 i = 1; i < index; ++i) {
373+
lastTranscoder = BONDING_MANAGER.getNextTranscoderInPool(lastTranscoder);
374+
}
375+
}
376+
377+
function _getLastTranscoder() internal view returns (address lastTranscoder) {
378+
return _getTranscoderAtIndex(100);
379+
}
380+
}

0 commit comments

Comments
 (0)