Weather Witness

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

Broken Access Control in fulfillMintRequest Allows NFT Theft

Root + Impact

Description

The requestMintWeatherNFT The function allows a user to request the minting of a weather-based NFT, which is processed via Chainlink Functions. The corresponding fulfillMintRequest function mints the NFT once a valid response is received. The expected behavior is that the NFT is minted to the original user who made the request.

However, there is a critical vulnerability in the fulfillMintRequest function. It fails to verify the identity of the caller or ensure that the NFT is minted to the originally requesting user. Instead, it mints the NFT to msg.sender, which can be any attacker calling the function before the legitimate response handler does.

This allows an attacker to steal NFTs by calling the function with a valid requestId before the legitimate backend does.

function fulfillMintRequest(bytes32 requestId) external {
...
UserMintRequest memory _userMintRequest = s_funcReqIdToUserMintReq[
requestId
];
...
// @audit-high Anyone Can mint NFTs
_mint(msg.sender, tokenId); // @> Vulnerability: Uses msg.sender instead of _userMintRequest.user

Risk

Likelihood:

  • This occurs when an attacker observes a valid requestId or listens for the WeatherNFTMintRequestSent event.

  • Chainlink Functions do not restrict who can call fulfillMintRequest, so any on-chain address can execute it before the legitimate fulfiller (oracle or backend) does.

Impact:

  • Attackers can front-run valid requests and mint NFTs that should have gone to other users.

  • Victims lose ETH (or LINK) during the mint process and receive nothing in return.

Proof of Concept

function testTakeoverMint() public {} {
uint256 tokenId = weatherNft.s_tokenCounter();
// Step 1: Approve LINK and Mint NFT
vm.startBroadcast(userPrivateKey);
linkToken.approve(address(weatherNft), initLinkDeposit);
weatherNft.requestMintWeatherNFT{value: weatherNft.s_currentMintPrice()}(
pincode,
isoCode,
registerKeeper,
heartbeat,
initLinkDeposit
);
vm.stopBroadcast();
// Step 2: Fetch requestId from logs
Vm.Log[] memory logs = vm.getRecordedLogs();
bytes32 reqId;
for (uint256 i = 0; 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));
// Step 3: Simulate fulfillment by Chainlink Functions
vm.prank(functionsRouter);
bytes memory weatherResponse = abi.encode(WeatherNftStore.Weather.RAINY);
weatherNft.handleOracleFulfillment(reqId, weatherResponse, "");
// Step 4: Takeover the NFT
vm.startBroadcast(attackerPrivateKey);
weatherNft.fulfillMintRequest(reqId);
vm.stopBroadcast();
}
  • Retrieves the current tokenId before minting begins.

  • Starts a broadcast using the user's private key.

  • Approves the WeatherNft contract to spend LINK tokens for Keeper registration.

  • Calls requestMintWeatherNFT to initiate the mint process and emits an event with a unique requestId.

  • It stops broadcasting after the mint request is sent.

  • Retrieves logs emitted during the transaction.

  • Iterates through the logs to find the WeatherNFTMintRequestSent event and extract the corresponding requestId.

  • Ensures that a valid requestId value was retrieved by asserting it's not zero.

  • Used vm.prank to impersonate the functionsRouter (i.e., Chainlink Functions node).

  • Calls handleOracleFulfillment with requestId and a forged weather response (e.g., RAINY).

  • Starts a broadcast using an attacker’s private key.

  • Calls fulfillMintRequest using the previously captured requestId.

  • Mints the NFT to the attacker’s address instead of the legitimate user.

  • Ends the broadcast, completing the unauthorized NFT mint.

Recommended Mitigation

- _mint(msg.sender, tokenId);
+ _mint(_userMintRequest.user, tokenId);

Additionally, implement a validation mechanism to ensure only trusted sources (e.g., the Chainlink Functions oracle) can fulfill the request:

require(msg.sender == trustedOracleAddress, "Unauthorized fulfiller");

Or, if applicable, use Chainlink Functions’ callbackGasLimit and fulfill functions that support onlyFunctionsOracle A pattern to restrict access to fulfillers.

Updates

Appeal created

bube Lead Judge 23 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Lack of ownership check in `fulfillMintRequest` function

There is no check to ensure that the caller of the `fulfillMintRequest` function is actually the owner of the `requestId`. This allows a malicious user to receive a NFT that is payed from someone else.

Support

FAQs

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