Platform: Code4rena
Start Date: 14/06/2022
Pot Size: $100,000 USDC
Total HM: 26
Participants: 59
Period: 7 days
Judge: GalloDaSballo
Total Solo HM: 9
Id: 133
League: ETH
Rank: 1/59
Findings: 23
Award: $16,676.35
🌟 Selected for report: 4
🚀 Solo Findings: 4
126.3383 USDC - $126.34
782.2807 CANTO - $126.34
function updateBaseRate(uint newBaseRatePerYear) public { // check the current block number uint blockNumber = block.number; uint deltaBlocks = blockNumber.sub(lastUpdateBlock); if (deltaBlocks > updateFrequency) { // pass in a base rate per year baseRatePerYear = newBaseRatePerYear; lastUpdateBlock = blockNumber; emit NewInterestParams(baseRatePerYear); } }
Add proper access control.
#0 - ecmendenhall
2022-06-22T02:52:06Z
#1 - tkkwon1998
2022-06-22T19:34:44Z
Duplicate of issue #22
#2 - GalloDaSballo
2022-08-07T21:27:24Z
Dup of #22
1207.2233 CANTO - $194.97
194.9666 USDC - $194.97
function approve(address owner, address spender) external returns(bool) { _approve(owner, spender, _balanceOf[owner]); return true; }
approve(address owner, address spender)
is a public function with no access control, the attacker can call it and approve from an arbitrary address (owner
) to their own address (spender
) and empty their balances.
#0 - nivasan1
2022-06-21T22:21:20Z
duplicate of issue #19
#1 - GalloDaSballo
2022-08-07T21:32:13Z
Dup #19
1467.456 USDC - $1,467.46
9086.4148 CANTO - $1,467.46
function sweepInterest() external override returns(uint) { uint noteBalance = note.balanceOf(address(this)); uint CNoteBalance = cnote.balanceOf(address(this)); Exp memory expRate = Exp({mantissa: cnote.exchangeRateStored()}); // obtain exchange Rate from cNote Lending Market as a mantissa (scaled by 1e18) uint cNoteConverted = mul_ScalarTruncate(expRate, CNoteBalance); //calculate truncate(cNoteBalance* mantissa{expRate}) uint noteDifferential = sub_(note.totalSupply(), noteBalance); //cannot underflow, subtraction first to prevent against overflow, subtraction as integers require(cNoteConverted >= noteDifferential, "Note Loaned to LendingMarket must increase in value"); uint amtToSweep = sub_(cNoteConverted, noteDifferential); note.transfer(treasury, amtToSweep); cnote.transfer(address(0), CNoteBalance); return 0; }
cnote.transfer(address(0), CNoteBalance);
will burn all the balance in the Accountant
contract, which makes no sense and will cause the Accountant
contract to lose most of the funds and malfunction ever since.
Remove cnote.transfer(address(0), CNoteBalance);
.
#0 - tkkwon1998
2022-06-22T19:36:05Z
Duplicate of issue #89
#1 - GalloDaSballo
2022-08-04T21:39:57Z
Dup of #89
3679.998 CANTO - $594.32
594.3197 USDC - $594.32
function getBorrowRate(uint cash, uint borrows, uint reserves) public view override returns (uint) { // Gets the Note/gUSDC TWAP in a given interval, as a mantissa (scaled by 1e18) // uint twapMantissa = getUnderlyingPrice(note); uint rand = uint(keccak256(abi.encodePacked(msg.sender))) % 100; uint ir = (100 - rand).mul(adjusterCoefficient).add(baseRatePerYear).mul(1e16); uint newRatePerYear = ir >= 0 ? ir : 0; // convert it to base rate per block uint newRatePerBlock = newRatePerYear.div(blocksPerYear); return newRatePerBlock; }
The current implementation will return a random rate based on the caller's address and baseRatePerYear
.
This makes some lucky addresses pay much lower and some addresses pay much higher rates.
#0 - nivasan1
2022-06-21T18:24:08Z
duplicate of issue #202
#1 - GalloDaSballo
2022-08-04T21:43:28Z
Making this primary
#2 - GalloDaSballo
2022-08-04T21:44:31Z
The warden has shown how, due to most likely a developer oversight, the unimplemented getBorrowRate
returns a random value which can easily be gamed (and is not recommended for production).
Because the contract is in scope, and the functionality is broken, I agree with High Severity
🌟 Selected for report: WatchPug
3261.0133 USDC - $3,261.01
20192.0328 CANTO - $3,261.01
function pairFor(address factory, address tokenA, address tokenB) internal pure returns (address pair) { (address token0, address token1) = sortTokens(tokenA, tokenB); pair = address(uint(keccak256(abi.encodePacked( hex'ff', factory, keccak256(abi.encodePacked(token0, token1)), hex'e18a34eb0e04b04f7a0ac29a6e80748dca96319b42c54d679cb821dca90c6303' // init code hash )))); }
The init code hash
in UniswapV2Library.pairFor()
should be updated since the code of UniswapV2Pair
has been changed. Otherwise, the pair
address calculated will be wrong, most likely non-existing address.
There are many other functions and other contracts across the codebase, including UniswapV2Oracle
, UniswapV2Router02
, and SushiRoll
, that rely on the UniswapV2Library.pairFor()
function for the address of the pair, with the UniswapV2Library.pairFor()
returning a wrong and non-existing address, these functions and contracts will malfunction.
Update the init code hash from hex'e18a34eb0e04b04f7a0ac29a6e80748dca96319b42c54d679cb821dca90c6303'
to the value of UniswapV2Factory.pairCodeHash()
.
#0 - GalloDaSballo
2022-08-07T21:26:45Z
Amazing catch, because the contract bytecode has been change, the init hash will be different.
While the bug seems trivial, it's impact is a total bricking of all swapping functionality as the Library will cause all Periphery Contracts to call to the wrong addresses.
Because of the impact, I agree with High Severity
🌟 Selected for report: Soosh
Also found by: 0x1f8b, Ruhum, TerrierLover, WatchPug, cccz, csanuragjain, hake, p4st13r4, zzzitron
126.3383 USDC - $126.34
782.2807 CANTO - $126.34
function AddProposal(uint propId, string memory title, string memory desc, address[] memory targets, uint[] memory values, string[] memory signatures, bytes[] memory calldatas) public { Proposal memory newProp = Proposal(propId, title, desc, targets, values, signatures, calldatas); proposals[propId] = newProp; }
Proposal-Store.sol#AddProposal()
is a public function, and anyone call add a new proposal with arbitrary properties, or update a previous proposal's properties.
The malicious proposal can then be queued and executed on lending-market/GovernorBravoDelegate
and so.
function queue(uint proposalId) external { // Query the proposal from the unigov map contract IProposal.Proposal memory unigovProposal = unigov.QueryProp(proposalId); // Allow addresses above proposal threshold and whitelisted addresses to propose require(unigovProposal.targets.length == unigovProposal.values.length && unigovProposal.targets.length == unigovProposal.signatures.length && unigovProposal.targets.length == unigovProposal.calldatas.length, "GovernorBravo::propose: proposal function information arity mismatch"); require(unigovProposal.targets.length != 0, "GovernorBravo::propose: must provide actions"); require(unigovProposal.targets.length <= proposalMaxOperations, "GovernorBravo::propose: too many actions"); // Add proposal to proposals storage Proposal storage newProposal = proposals[unigovProposal.id]; // Make sure you are not overriding an existing proposal require(proposals[unigovProposal.id].id == 0); // Set newProposal to the fields of unigov proposal newProposal.id = unigovProposal.id; newProposal.eta = 0; newProposal.targets = unigovProposal.targets; newProposal.values = unigovProposal.values; newProposal.signatures = unigovProposal.signatures; newProposal.calldatas = unigovProposal.calldatas; newProposal.canceled = false; newProposal.executed = true; uint eta = add256(block.timestamp, timelock.delay()); for (uint i = 0; i < newProposal.targets.length; i++) { queueOrRevertInternal(newProposal.targets[i], newProposal.values[i], newProposal.signatures[i], newProposal.calldatas[i], eta); } newProposal.eta = eta; emit ProposalQueued(proposalId, eta); }
Add proper access control.
#0 - tkkwon1998
2022-06-22T19:41:16Z
Duplicate of #26
427.9102 USDC - $427.91
2649.5985 CANTO - $427.91
function borrowFresh(address payable borrower, uint borrowAmount) internal override { /* Fail if borrow not allowed */ uint allowed = comptroller.borrowAllowed(address(this), borrower, borrowAmount); if (allowed != 0) { revert BorrowComptrollerRejection(allowed); } /* Verify market's block number equals current block number */ if (accrualBlockNumber != getBlockNumber()) { revert BorrowFreshnessCheck(); } require(getCashPrior() == 0, "CNote::borrowFresh:Impossible reserves in CNote market Place"); require(address(_accountant) != address(0), "CNote::borrowFresh:Accountant has not been initialized"); /*Protocol always has a balance of 0 Note, thus the accountant must mint borrowAmount tokens */ uint err = _accountant.supplyMarket(borrowAmount); if (err != 0) { revert AccountantSupplyError(borrowAmount); } require(getCashPrior() == borrowAmount, "CNote::borrowFresh:Error in Accountant supply"); /* * We calculate the new borrower and total borrow balances, failing on overflow: * accountBorrowsNew = accountBorrows + borrowAmount * totalBorrowsNew = totalBorrows + borrowAmount */ uint accountBorrowsPrev = borrowBalanceStoredInternal(borrower); uint accountBorrowsNew = accountBorrowsPrev + borrowAmount; uint totalBorrowsNew = totalBorrows + borrowAmount; ///////////////////////// // EFFECTS & INTERACTIONS // (No safe failures beyond this point) /* * We invoke doTransferOut for the borrower and the borrowAmount. * Note: The cToken must handle variations between ERC-20 and ETH underlying. * On success, the cToken borrowAmount less of cash. * doTransferOut reverts if anything goes wrong, since we can't be sure if side effects occurred. */ doTransferOut(borrower, borrowAmount); require(getCashPrior() == 0,"CNote::borrowFresh: Error in doTransferOut, impossible Liquidity in LendingMarket"); //Amount minted by Accountant is always flashed from account /* We write the previously calculated values into storage */ accountBorrows[borrower].principal = accountBorrowsNew; accountBorrows[borrower].interestIndex = borrowIndex; totalBorrows = totalBorrowsNew; /* We emit a Borrow event */ emit Borrow(borrower, borrowAmount, accountBorrowsNew, totalBorrowsNew); }
Both borrowFresh()
and repayBorrowFresh()
checks and requires getCashPrior() == 0
, which mean the balance of NOTE must be 0, otherwise, the borrowFresh()
and repayBorrowFresh()
will revert with an error.
However, anyone can send 1 wei of NOTE token to the CNote contract, and break the two most essential functions of the cNote contract.
Consider changing the checks and do not rely on balanceOf()
.
#0 - ecmendenhall
2022-06-21T22:53:16Z
#1 - tkkwon1998
2022-06-22T18:49:57Z
Duplicate of issue #227
🌟 Selected for report: p4st13r4
Also found by: Ruhum, TerrierLover, WatchPug, hansfriese, zzzitron
320.9326 USDC - $320.93
1987.1989 CANTO - $320.93
function totalSupply() public view returns (uint) { return _balanceOf[address(this)]; }
The totalSupply
is supposed to return the total supply of the token.
In the current implementation, it's returning the balance of the contract itself, which will always be 0 unless tokens get sent to the contract for some reason (that is quite unlikely to happen, because doing that means losing the funds).
Change to:
function totalSupply() public view returns (uint) { return address(this).balance; }
#0 - ecmendenhall
2022-06-21T22:27:33Z
#1 - nivasan1
2022-06-23T21:13:50Z
duplicate of #191
320.9326 USDC - $320.93
1987.1989 CANTO - $320.93
function grantCompInternal(address user, uint amount) internal returns (uint) { WETH comp = WETH(payable(getWETHAddress())); uint compRemaining = comp.balanceOf(address(this)); if (amount > 0 && amount <= compRemaining) { comp.transfer(user, amount); return 0; } return amount; }
function getWETHAddress() virtual public view returns (address) { return 0xc00e94Cb662C3520282E6f5717214004A7f26888; }
While the function name of getCompAddress()
get changed to getWETHAddress()
, the address itself (0xc00e94Cb662C3520282E6f5717214004A7f26888
) remain unchanged.
As a result, the compRemaining
in grantCompInternal()
will always be 0
.
Consider adding an immutable variable to the constructor for WETH.
#0 - ecmendenhall
2022-06-21T22:36:46Z
#1 - tkkwon1998
2022-06-22T19:37:02Z
Duplicate of #46
2649.5985 CANTO - $427.91
427.9102 USDC - $427.91
function _mint_to_Accountant(address accountantDelegator) external { if (accountant == address(0)) { _setAccountantAddress(msg.sender); } require(msg.sender == accountant, "Note::_mint_to_Accountant: "); _mint(msg.sender, type(uint).max); } function RetAccountant() public view returns(address) { return accountant; } function _setAccountantAddress(address accountant_) internal { if(accountant != address(0)) { require(msg.sender == admin, "Note::_setAccountantAddress: Only admin may call this function"); } accountant = accountant_; admin = accountant; }
_mint_to_Accountant()
calls _setAccountantAddress()
when accountant == address(0)
, which will always be the case when _mint_to_Accountant()
is called for the first time.
And _setAccountantAddress()
only checks if msg.sender == admin
when accountant != address(0)
which will always be false
, therefore the access control is not working.
L17 will then check if msg.sender == accountant
, now it will always be the case, because at L29, accountant
was set to msg.sender
.
#0 - GalloDaSballo
2022-08-10T23:01:06Z
The warden has shown how, due to a flaw in logic, via a front-run, anyone can become the accountant
and mint all the totalSupply to themselves.
While I'm not super confident on severity for the front-run as I'd argue the worst case is forcing a re-deploy, the warden has shown a lack of logic in the checks (msg.sender == admin
) which breaks it's invariants.
For that reason, I think High Severity to be appropriate
🌟 Selected for report: WatchPug
6057.6098 CANTO - $978.30
978.304 USDC - $978.30
if (_totalSupply == 0) { address migrator = IUniswapV2Factory(factory).migrator(); if (msg.sender == migrator) { liquidity = IMigrator(migrator).desiredLiquidity(); require(liquidity > 0 && liquidity != uint256(-1), "Bad desired liquidity"); } else { require(migrator == address(0), "Must not have migrator"); liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY); _mint(address(0), MINIMUM_LIQUIDITY); // permanently lock the first MINIMUM_LIQUIDITY tokens } } else { liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1); }
function migrate(IUniswapV2Pair orig) public returns (IUniswapV2Pair) { require(msg.sender == chef, "not from master chef"); require(block.number >= notBeforeBlock, "too early to migrate"); require(orig.factory() == oldFactory, "not from old factory"); address token0 = orig.token0(); address token1 = orig.token1(); IUniswapV2Pair pair = IUniswapV2Pair(factory.getPair(token0, token1)); if (pair == IUniswapV2Pair(address(0))) { pair = IUniswapV2Pair(factory.createPair(token0, token1)); } uint256 lp = orig.balanceOf(msg.sender); if (lp == 0) return pair; desiredLiquidity = lp; orig.transferFrom(msg.sender, address(orig), lp); orig.burn(address(pair)); pair.mint(msg.sender); desiredLiquidity = uint256(-1); return pair; }
When minting LP tokens (addLiquidity
), the amount of lp tokens you are getting is calculated based on liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1);
, if the _totalSupply
is small enough, and 1 wei
of the lp token worth large amounts of token0
and token1
, the user who adds small amounts of liquidity will receive less amount of lp tokens due to precision loss.
A sophisticated attacker can artificially create that scenario by mint only 1 wei
of lp token and add 1e24
or even larger amounts of token0
and token1
by sending the tokens to the contract and then call sync()
to update the reserves.
Then all the new depositors will lose up to 1e24
, let's say they deposited 1.99e24
, they will only receive 1 wei
of lp token, therefore, losing 0.99e24
of token0
and token1
.
This attack vector was mitigated the original version of UniswapV2Pair by introcuing the MINIMUM_LIQUIDITY
minted and permanently lock in address(0)
upon the first mint.
However, this can now be bypassed with the migrator, and this attacker vector is open again.
Given the fact that zeroswap will be a DEX that does not need a feature to migrate liquidity from other DEXs, consider removing the migrator.
#0 - tkkwon1998
2022-06-22T19:03:10Z
Issue acknowledged, we will not be migrating liquidity from zeroswap.
#1 - GalloDaSballo
2022-08-10T22:48:57Z
The warden has shown how, due to legacy code, an LP pair can be permissionlessly minted and setup to cause loss to future depositors.
Deployment of pairs is permissionless, however, the setup of Factory is Admin Dependent.
While the Migrator file was out of scope, I believe the sponsor acknowledging, and the code being on the Pair file allows the finding to be valid.
However, because it is ultimately contingent on setup, I think Medium Severity to be more appropriate.
🌟 Selected for report: joestakey
Also found by: 0x1f8b, 0x29A, 0x52, 0xDjango, 0xNazgul, 0xf15ers, 0xmint, Bronicle, Dravee, Funen, JMukesh, Limbooo, MadWookie, Picodes, Ruhum, TerrierLover, TomJ, Tutturu, WatchPug, Waze, _Adam, asutorufos, c3phas, catchup, cccz, codexploder, cryptphi, csanuragjain, defsec, fatherOfBlocks, gzeon, hake, hansfriese, hyh, ignacio, k, nxrblsrpr, oyc_109, robee, sach1r0, saian, simon135, technicallyty, zzzitron
72.3997 USDC - $72.40
687.9945 CANTO - $111.11
function _update(uint balance0, uint balance1, uint _reserve0, uint _reserve1) internal { uint blockTimestamp = block.timestamp; uint timeElapsed = blockTimestamp - blockTimestampLast; // overflow is desired if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) { reserve0CumulativeLast += _reserve0 * timeElapsed; reserve1CumulativeLast += _reserve1 * timeElapsed; } Observation memory _point = lastObservation(); timeElapsed = blockTimestamp - _point.timestamp; // compare the last observation with current timestamp, if greater than 30 minutes, record a new event if (timeElapsed > periodSize) { observations.push(Observation(blockTimestamp, reserve0CumulativeLast, reserve1CumulativeLast)); } reserve0 = balance0; reserve1 = balance1; blockTimestampLast = blockTimestamp; emit Sync(reserve0, reserve1); }
This was forked from Uniswap v2's update()
:
https://github.com/Uniswap/v2-core/blob/master/contracts/UniswapV2Pair.sol#L72-L81
// update reserves and, on the first call per block, price accumulators function _update(uint balance0, uint balance1, uint112 _reserve0, uint112 _reserve1) private { require(balance0 <= uint112(-1) && balance1 <= uint112(-1), 'UniswapV2: OVERFLOW'); uint32 blockTimestamp = uint32(block.timestamp % 2**32); uint32 timeElapsed = blockTimestamp - blockTimestampLast; // overflow is desired if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) { // * never overflows, and + overflow is desired price0CumulativeLast += uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed; price1CumulativeLast += uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed; }
UniswapV2's Pair is using Solidity 0.5.16, in which the arithmetic operations will overflow/underflow without revert.
As the solidity version used in the current implementation of BaseV1Pair.sol
is 0.8.11
, and there are some breaking changes in Solidity v0.8.0, including:
Arithmetic operations revert on underflow and overflow.
Ref: https://docs.soliditylang.org/en/v0.8.11/080-breaking-changes.html#silent-changes-of-the-semantics
When updating reserve0CumulativeLast
and reserve1CumulativeLast
in BaseV1Pair.sol
, overflow and underflow are desired as per the comment.
However, the intended overflow only works for solidity < 0.8.0
by default. If overflow and underflow are desired, then the math should be put into an unchecked
block. Otherwise, the transaction will revert.
Since the overflow is desired in the original version, and it's broken because of using Solidity version >0.8. The BaseV1Pair
contract will break when the desired overflow happens, which will be sooner or later depending on the decimals of the tokens and trading volume.
Change to:
unchecked { uint timeElapsed = blockTimestamp - blockTimestampLast; if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) { reserve0CumulativeLast += _reserve0 * timeElapsed; reserve1CumulativeLast += _reserve1 * timeElapsed; } }
#0 - GalloDaSballo
2022-08-07T21:24:50Z
See https://github.com/code-423n4/2022-05-velodrome-findings/issues/148#issuecomment-1169399249 (not yet public)
Because I believe the finding is valid, however it would require:
1 Billion Tokens with 18 decimals, with a Million Trades per Second, over 1 thousand years will still be 30 digits away from overflowing
I think QA is more appropriate