Platform: Code4rena
Start Date: 26/07/2022
Pot Size: $75,000 USDC
Total HM: 29
Participants: 179
Period: 6 days
Judge: LSDan
Total Solo HM: 6
Id: 148
League: ETH
Rank: 22/179
Findings: 5
Award: $526.19
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: GimelSec
Also found by: 0x52, 0xA5DF, 0xSky, 0xsanson, Bahurum, CertoraInc, GalloDaSballo, JohnSmith, Lambda, MEP, Twpony, arcoun, berndartmueller, cryptphi, hansfriese, kenzo, kyteg, panprog, rajatbeladiya, scaraven, simon135, zzzitron
26.7695 USDC - $26.77
MED - Function could be impacted
The delegate function does not work properly, as the result, vote functionality is impacted
VoteEscrowDelegation
, the delegate
function calls _writeCheckpoint
even when numCheckpoints
is zero. When numCheckpoints
is zero, _writeCheckpoint
tries to access a array element of minus one (line 101), which results to revert with underflow panic. Since the first attempt to delegate fails, nobody can delegate any votes to anybody.The getVotes
will only count the delegated votes (not the balance of its own), nobody has any vote.
delegate
function for the possibility of multiple delegation, but since the delegation function itself does not work, multiple delegation was not possible to be tested. The vote was counted based on the checkpoints
and it does not check the delegates
. So, one can delegate
multiple times and the same tokenId
can be pushed to the delegatedTokenIds
and it will be counted as a valid vote for multiple times.// VoteEscrowDelegation: delegate and _writeCheckpoint 71 function delegate(uint256 tokenId, uint256 toTokenId) external { 72 require(ownerOf(tokenId) == msg.sender, 'VEDelegation: Not allowed'); 73 require(this.balanceOfNFT(tokenId) >= MIN_VOTING_POWER_REQUIRED, 'VEDelegation: Need more voting power'); 74 75 delegates[tokenId] = toTokenId; 76 uint256 nCheckpoints = numCheckpoints[toTokenId]; 77 78 if (nCheckpoints > 0) { 79 Checkpoint storage checkpoint = checkpoints[toTokenId][nCheckpoints - 1]; 80 checkpoint.delegatedTokenIds.push(tokenId); 81 _writeCheckpoint(toTokenId, nCheckpoints, checkpoint.delegatedTokenIds); 82 } else { 83 uint256[] memory array = new uint256[](1); 84 array[0] = tokenId; 85 _writeCheckpoint(toTokenId, nCheckpoints, array); 86 } 87 88 emit DelegateChanged(tokenId, toTokenId, msg.sender); 89 } 90 91 /** 92 * @notice Writes the checkpoint to store current NFTs in the specific block 93 */ 94 function _writeCheckpoint( 95 uint256 toTokenId, 96 uint256 nCheckpoints, 97 uint256[] memory _delegatedTokenIds 98 ) internal { 99 require(_delegatedTokenIds.length < 500, 'VVDelegation: Cannot stake more'); 100 101 Checkpoint memory oldCheckpoint = checkpoints[toTokenId][nCheckpoints - 1]; 102 103 if (nCheckpoints > 0 && oldCheckpoint.fromBlock == block.number) { 104 oldCheckpoint.delegatedTokenIds = _delegatedTokenIds; 105 } else { 106 checkpoints[toTokenId][nCheckpoints] = Checkpoint(block.number, _delegatedTokenIds); 107 numCheckpoints[toTokenId] = nCheckpoints + 1; 108 } 109 }
hardhat
The line 101 should be triggered only when the nCheckpoints > 0
.
<!-- zzzitron 02M -->94 function _writeCheckpoint( 95 uint256 toTokenId, 96 uint256 nCheckpoints, 97 uint256[] memory _delegatedTokenIds 98 ) internal { 99 require(_delegatedTokenIds.length < 500, 'VVDelegation: Cannot stake more'); 100 103 if (nCheckpoints > 0 && oldCheckpoint.fromBlock == block.number) { Checkpoint memory oldCheckpoint = checkpoints[toTokenId][nCheckpoints - 1]; if (oldCheckpoint.fromBlock == block.number) { 104 oldCheckpoint.delegatedTokenIds = _delegatedTokenIds; } else { checkpoints[toTokenId][nCheckpoints] = Checkpoint(block.number, _delegatedTokenIds); numCheckpoints[toTokenId] = nCheckpoints + 1; } 105 } else { 106 checkpoints[toTokenId][nCheckpoints] = Checkpoint(block.number, _delegatedTokenIds); 107 numCheckpoints[toTokenId] = nCheckpoints + 1; 108 } 109 }
#0 - KenzoAgada
2022-08-02T09:20:24Z
Duplicate of #630
93.2805 USDC - $93.28
HIGH - assets can be lost directly.
When fillCriterialBid
and fillBid
are called for multiple NFTs, the taker of the bid will get less ether then they should get, and the ether will be lost in the GolomTrader.
The _settleBalances
is counting the protocolfee multiple times. In the line 381, the protocolfee
is counted for the amount
of NFTs. However upon subtracting the fees from the totalAmt
, the protocolfee
is again multiplied (line 390, 397). As the result the taker of the deal will get paid less ether then they should be. The ether the taker should get will be lost in the GolomTrader
contract as there is no method to rescue the fund.
For example, the totalAmt
is 10 ether and amount
is 10, the protocolfee
is 0.5 ether. For simplicity let's say other fees are zero. The taker of the bid, then, should get 99.5 (= 100 - 0.5). However, the taker will only get 95 ((10 - 0.5) * 10). The difference will be lost in the GolomTrader
contract.
// https://github.com/code-423n4/2022-07-golom/blob/e5efa8f9d6dda92a90b8b2c4902320acf0c26816/contracts/core/GolomTrader.sol#L375-L403 // GolomTrader::_settleBalances // used in fillCriteriaBid and fillBid // the protocolfee already counts for amount // but again multiplied in the payEther (line 390, 397) 381 uint256 protocolfee = ((o.totalAmt * 50) / 10000) * amount; 382 WETH.transferFrom(o.signer, address(this), o.totalAmt * amount); 383 WETH.withdraw(o.totalAmt * amount); 384 payEther(protocolfee, address(distributor)); 385 payEther(o.exchange.paymentAmt * amount, o.exchange.paymentAddress); 386 payEther(o.prePayment.paymentAmt * amount, o.prePayment.paymentAddress); 387 if (o.refererrAmt > 0 && referrer != address(0)) { 388 payEther(o.refererrAmt * amount, referrer); 389 payEther( 390 (o.totalAmt - protocolfee - o.exchange.paymentAmt - o.prePayment.paymentAmt - o.refererrAmt) * 391 amount - 392 p.paymentAmt, 393 msg.sender 394 ); 395 } else { 396 payEther( 397 (o.totalAmt - protocolfee - o.exchange.paymentAmt - o.prePayment.paymentAmt) * amount - p.paymentAmt, 398 msg.sender 399 ); 400 }
None
Subtract protocolfee only once.
<!-- zzzitron 00H -->// GolomTrader::_settleBalances // mitigation suggestion 387 if (o.refererrAmt > 0 && referrer != address(0)) { 388 payEther(o.refererrAmt * amount, referrer); 389 payEther( 390 (o.totalAmt - o.exchange.paymentAmt - o.prePayment.paymentAmt - o.refererrAmt) * 391 amount - protocolfee - 392 p.paymentAmt, 393 msg.sender 394 ); 395 } else { 396 payEther( 397 (o.totalAmt - o.exchange.paymentAmt - o.prePayment.paymentAmt) * amount - protocolfee - p.paymentAmt, 398 msg.sender 399 ); 400 }
#0 - KenzoAgada
2022-08-02T06:32:15Z
Duplicate of #240
🌟 Selected for report: cloudjunky
Also found by: 0x1f8b, 0x4non, 0x52, 0xDjango, 0xHarry, 0xNazgul, 0xNineDec, 0xf15ers, 0xsanson, 0xsolstars, 8olidity, Bnke0x0, CertoraInc, Chom, Deivitto, Dravee, GalloDaSballo, GimelSec, IllIllI, Jmaxmanblue, JohnSmith, Jujic, Kenshin, Krow10, Lambda, MEP, Noah3o6, RedOneN, Ruhum, StErMi, StyxRave, TomJ, Treasure-Seeker, TrungOre, _Adam, __141345__, arcoun, asutorufos, bardamu, bearonbike, bin2chen, brgltd, bulej93, c3phas, cRat1st0s, carlitox477, cccz, codexploder, cryptonue, cryptphi, cthulhu_cult, dharma09, dipp, djxploit, durianSausage, ellahi, giovannidisiena, hansfriese, horsefacts, hyh, immeas, indijanc, jayjonah8, jayphbee, joestakey, kenzo, kyteg, ladboy233, minhquanym, navinavu, obront, oyc_109, peritoflores, rbserver, reassor, rokinot, rotcivegaf, saian, scaraven, shenwilly, simon135, sseefried, teddav, zzzitron
0.0037 USDC - $0.00
Judge has assessed an item in Issue #703 as Medium risk. The relevant finding follows:
L01: Usage of transfer to send eth It is recommended to use call instead of transfer due to fixed gas stipend. In the GolomTrader, transfer is used to pay ether.
#0 - dmvt
2022-10-21T16:00:27Z
Duplicate of #343
🌟 Selected for report: zzzitron
Also found by: GimelSec, GiveMeTestEther, berndartmueller, sseefried
370.9691 USDC - $370.97
https://github.com/code-423n4/2022-07-golom/blob/7bbb55fca61e6bae29e57133c1e45806cbb17aa4/contracts/governance/GolomToken.sol#L58-L72 https://github.com/code-423n4/2022-07-golom/blob/7bbb55fca61e6bae29e57133c1e45806cbb17aa4/contracts/core/GolomTrader.sol#L444-L457 https://github.com/code-423n4/2022-07-golom/blob/7bbb55fca61e6bae29e57133c1e45806cbb17aa4/contracts/rewards/RewardDistributor.sol#L298-L311
MED - Function could be impacted
As the timelock does not work as supposed to work, the owner of the contract can bypass timelock.
GolomToken
: setMinter
, executeSetMinter
GolomTrader
: setDistributor
, executeSetDistributor
RewardDistributor
: addVoteEscrow
, executeAddVoteEscrow
The first poc shows to bypass timelock for GolomTrader::setDistributor
. The same logic applies for the RewardDistributor::addVoteEscrow
.
0. The setDistributor
was called once in the beforeEach block to set the initial distributor. For this exploit to work, the setDistributor
should be called only once. If setDistributor
was called more than once, one can set the distributor to zero address (with timelock like in the GolomToken
case, then set to a new distributor after that)
executeSetDistributor
setDistributor
setDistributor
is not called multiple times in row, the owner can keep setting distributor without timelock.A little bit different variation of timelock bypass was found in the GolomToken
. Although the owner should wait for the timelock to set the minter to zero address, but after that, the owner can set to the new minter without waiting for a timelock. Since the meaning of timelock is to let people know the new minter's implementation, if the owner can bypass that, the timelock is almost meaningless.
The exploitation steps: the second proof of concept
setMineter
with zero addressexecuteSetMineter
to set the minter to zero addresssetMineter
with any address and call executeSetMinter
without waiting for the timelockThe owner can call executeSetdistributor
even though there is no pendingDistributor
set before. Also, setDistributor
sets the new distributor without timelock when the existing distributor's address is zero.
// GolomTrader // almost identical logic was used in `RewardDistributor` to addVoteEscrow // similar logic was used in `GolomToken` to `setMineter` 444 function setDistributor(address _distributor) external onlyOwner { 445 if (address(distributor) == address(0)) { 446 distributor = Distributor(_distributor); 447 } else { 448 pendingDistributor = _distributor; 449 distributorEnableDate = block.timestamp + 1 days; 450 } 451 } 452 453 /// @notice Executes the set distributor function after the timelock 454 function executeSetDistributor() external onlyOwner { 455 require(distributorEnableDate <= block.timestamp, 'not allowed'); 456 distributor = Distributor(pendingDistributor); 457 }
None
To mitigate, execute functions can check whether pendingDistributor is not zero. It will ensure that the setters are called before executing them, as well as prevent to set to zero addresses.
<!-- zzzitron 01M -->#0 - 0xsaruman
2022-08-16T17:39:31Z
call setMineter with zero address wait for the timelock
This alone will trigger awareness that something malicious is happening and since timelock is there people have time to get out.
#1 - 0xsaruman
2022-08-18T17:27:45Z
the second POC is valid
#2 - 0xsaruman
2022-09-05T09:10:29Z
removed all secondary time locks in the contract and only using the primary timelock that will be behind the owner.
🌟 Selected for report: IllIllI
Also found by: 0x1f8b, 0x4non, 0x52, 0xA5DF, 0xDjango, 0xLovesleep, 0xNazgul, 0xNineDec, 0xSmartContract, 0xackermann, 0xc0ffEE, 0xf15ers, 0xmatt, 0xsanson, 0xsolstars, 8olidity, AuditsAreUS, Bahurum, Bnke0x0, CRYP70, CertoraInc, Ch_301, Chom, CryptoMartian, Deivitto, DevABDee, Dravee, ElKu, Franfran, Funen, GalloDaSballo, GimelSec, GiveMeTestEther, Green, JC, Jmaxmanblue, JohnSmith, Jujic, Junnon, Kenshin, Krow10, Kumpa, Lambda, MEP, Maxime, MiloTruck, Mohandes, NoamYakov, Picodes, RedOneN, Rohan16, Rolezn, Ruhum, RustyRabbit, Sm4rty, Soosh, StErMi, StyxRave, Tadashi, TomJ, Treasure-Seeker, TrungOre, Waze, _Adam, __141345__, ajtra, ak1, apostle0x01, arcoun, asutorufos, async, benbaessler, berndartmueller, bin2chen, brgltd, c3phas, cRat1st0s, carlitox477, chatch, codetilda, codexploder, cryptonue, cryptphi, csanuragjain, cthulhu_cult, delfin454000, dipp, dirk_y, djxploit, ellahi, exd0tpy, fatherOfBlocks, giovannidisiena, hansfriese, horsefacts, hyh, idkwhatimdoing, indijanc, jayfromthe13th, jayphbee, joestakey, kenzo, kyteg, lucacez, luckypanda, mics, minhquanym, obront, oyc_109, pedr02b2, rajatbeladiya, rbserver, reassor, robee, rokinot, rotcivegaf, sach1r0, saian, saneryee, sashik_eth, scaraven, shenwilly, simon135, sseefried, supernova, teddav, ych18, zuhaibmohd, zzzitron
35.1687 USDC - $35.17
Risk | Title |
---|---|
L00 | Missing zero address check |
L01 | Usage of transfer to send eth |
L02 | Unreachable code |
L03 | RewardToken can mint over 1 billion |
L04 | Not following standard |
L05 | Over paid eth will be lost |
L06 | Not sufficient check |
NC00 | Usage of magic number |
NC01 | API key in the hardhat config |
In GolomTrader, if the distributor
is zero, the fee will be sent to zero address:
distributor
is not initialized in the constructor, also it can be set to zero with setDistributor
fillAsk
, fillBid
and fillCriteriaBid
allows to send ether to zero address if the maker and taker sets so.check for _governance
(the owner)
It is recommended to use call
instead of transfer
due to fixed gas stipend. In the GolomTrader, transfer
is used to pay ether.
Since it reverts when signaturesigner
is not o.signer
, the part of code in the line 178-180 will never be reached.
// GolomTrader::validateOrder require(signaturesigner == o.signer, 'invalid signature'); if (signaturesigner != o.signer) { return (0, hashStruct, 0); }
Since the check for the totalSupply is happening before minting, it is possible to mint slightly more than 1 billion.
_balance
and thus balanceOf
do not throw for zero address, as standard ERC721
// from https://eips.ethereum.org/EIPS/eip-721 // interface ERC721 /// @notice Count all NFTs assigned to an owner /// @dev NFTs assigned to the zero address are considered invalid, and this /// function throws for queries about the zero address. /// @param _owner An address for whom to query the balance /// @return The number of NFTs owned by `_owner`, possibly zero function balanceOf(address _owner) external view returns (uint256);
In GolomTrader
, over paid eth is not returned to the taker and it will be lost in the contract.
The checks on totalAmt
is not sufficient for fillBid
and fillCriteriaBid
:
Payment
is not accounted
However, if the totalAmt
is not sufficient, the call will revert in the _settleBalances
with underflow// fillBid check require( o.totalAmt * amount > (o.exchange.paymentAmt + o.prePayment.paymentAmt + o.refererrAmt) * amount + p.paymentAmt ); // fillCriteriaBid check require(o.totalAmt >= o.exchange.paymentAmt + o.prePayment.paymentAmt + o.refererrAmt);
In the hardhat.config.ts
file, an API key for alchemy is hardcoded and it seems not to be disabled. Malicious actors can search through git commit history, so the API key should be retired.