🚩 Ethernaut Capture-the-flag: First 5 levels report
by Santiago González Rojas
April 2019
Summary
Ethernaut is a Web3/Solidity based wargame designed to expose players to common vulnerabilities found in smart contracts. This report documents my analysis and exploitation of the first five levels of the Ethernaut CTF challenge.
The purpose of this report is to provide a detailed walkthrough of each challenge, explaining the vulnerabilities discovered, how they can be exploited, and the underlying security concepts. Each level demonstrates different smart contract security flaws, including fallback function vulnerabilities, constructor naming issues, pseudo-randomness exploitation, transaction origin confusion, and integer overflow/underflow vulnerabilities.
Through this analysis, we explore how seemingly secure code can contain critical vulnerabilities that could lead to unauthorized access, fund theft, or contract manipulation. Understanding these vulnerabilities is crucial for developing secure smart contracts and conducting effective security audits.
0. Dependencies
- Truffle v5.0.3 (core: 5.0.3)
- Solidity - 0.4.19 (solc-js)
- Node v10.15.1
Fallback
1.1 Contract functions
- Fallback(): constructor: initialize the contract with a contribution of 100 ETH for the owner.
- contribute(): function to send ETH to the contract (min. value: 0.001 ETH). Then, sum the contribution to the mapping 'contributions', and if it is greater than the owner's contribution msg.sender, it will be the new contract owner.
- getContribution(): return the amount of ETH from msg.sender associated in the mapping.
- withdraw(): extract all the ETH in the contract, if the function caller is the contract owner.
- Fallback function: if anyone sends ETH to the contract and he or she had made a previous contribution, the msg.sender will be the new contract owner.
1.2 Analysis and vulnerabilities
To be the contract owner we have only one 'legal' option: make a contribution greater than the current owner.
But, watching the Fallback function:
function() payable public {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}The only thing that separates us from being the new owner is this required function. If we sent it directly to the contract some amount greater than 0 ETH (previously had contributed) will be the new contract owners. Once made such a contribution will withdraw all the money without problems.
Summary:
- Make a small contribution through contribute function.
- Send a transaction to the contract with a small amount of ETH to activate the fallback function.
Fallout
2.1 Contract functions
- Fall1out(): assign the value to send in msg.value to allocations mapping with msg.sender in key and converts it in the new owner.
- allocate(): sum the value send to the contract to the allocations.
- sendAllocations(): receive as a parameter an address, and verify with a require that has some amount of ETH in allocations. Then, transfer to this address the corresponding ETH.
- collectAllocations(): transfer all the balance to msg.sender.
- allocateBalance(): return the amount of ETH of _address.
2.2 Analysis and vulnerabilities
Let's take a look at the 'constructor':
function Fal1out() public payable {
owner = msg.sender;
allocations[owner] = msg.value;
}The only way to will be the new owner is through the Fal1out() function, and if we analyze carefully we can see that the name of the function is different from the name of the contract. Therefore, the contract has not a constructor. Fall1out() is a public function that could be called by everyone in the blockchain.
Simply put, what we have to do is call Fall1out() with a small amount of ETH, and then we will be the new contract owners.
Review:
- Send a small amount of ETH through Fall1out.
- With step 1, we are now the contract owners.
- We can call the withdraw() function to extract all the ETH.
CoinFlip
3.1 Contract functions
- CoinFlip(): constructor initializes the consecutive wins counter.
- Flip(_guess): receive a boolean as a parameter to "try to guess" the side of the coin, return True or False if it is correct or not.
BlockValue: is the value of the current block. If the actual hash is equal to the last hash the contract reverts the transaction
According to the Solidity documentation:
block.blockhash(uint blockNumber) returns (bytes32): hash of the given block - only works for 256 most recent, excluding current, blocksblock.number (uint): current block number
Then, the most important action, coinFlip, is done, dividing the blockValue by the FACTOR (it is important to note that FACTOR is constant). Then, that result is compared to _guess parameter and the consecutiveWins will be increased in the affirmative case.
3.2 Analysis and vulnerabilities
From the logic of the function, we can see that the "randomness" of the contract depends on two factors:
- FACTOR is always constant and already known.
- Current block number (already know too).
To understand more about how this contract can be exploited, it has to be understood how the blockchain works in Ethereum, how the blocks are mined, and how the transactions work.
It is important to know that each transaction is processed by all the network nodes, but only one of the nodes adds the block with the transaction to the network. Each block may contain many transactions; everything depends on how much gas is used in each transaction until reaching the maximum gas limit per block.
Anyway, if each execution of CoinFlip is executed in the same block, every instance will have the same block hash. Therefore, the two factors of "randomness": FACTOR constant and the blockhash could be induced manually. But seeing the implementation of Flip, we can observe that:
if (lastHash == blockValue) {
revert();
}If all the transactions are in the same block, the actual and the previous hash will always be the same. So, this if clause will not allow us to carry out this procedure.
Therefore, the following action can be performed: write a contract with the same structure of the Flip function, removing that if clause that does not allow to perform the aforementioned procedure.
If both functions have the same structure, the output will be the same. Consequently, the result can be "guessed" inside a contract created for us, and then pass the result to the contract at issue.
To sum up, the steps will be as follows:
- Create a new contract that has the same functionality except for the Flip function so that the previous and actual hash could be equal, removing the if clause.
- We use this contract to guess the side of the coin.
- Once the side of the coin is obtained, we send it to the aforementioned contract.
- Repeat the procedure ten times and ten consecutive wins will be obtained.
Telephone
4.1 Contract functions
- Telephone(): constructor. Initialize the contract owner.
- changeOwner(): modify the contract owner if the function caller is different from who start the whole transaction in the blockchain.
4.2 Analysis and vulnerabilities
There are two ways to be the contract owner: initializing it or calling changeOwner(_owner)
With the constructor we have nothing to do, so we analyze changeOwner function. The changeOwner function permits setting a new owner of the address if tx.origin is different from msg.sender.
According to Solidity documentation:
tx.origin (address): the sender of the transaction (full call chain).msg.sender (address): the sender of the message (current call).
Only user wallet addresses can be the tx.origin. Consequently, to be able to call the changeOwner function, a new contract should be created and then call Telephone.changeOwner from this contract. Therefore, the new contract address will be different from tx.origin.
The scenario will be (seeing the context from TelephoneAttack contract):
- User Wallet: tx.origin.
- Telephone contract: msg.sender.
- TelephoneAttack call Telephone's changeOwner function.
Token
5.1 Contract functions
- Token(_initialSupply): constructor. Initialize the contract with a specific amount of tokens as well as the initial contribution of msg.sender.
- Transfer(_address, _value): we will need that the amount of ETH available in balance less the amount that will be withdrawn, must be greater than zero. Then subtract _value to the corresponding key (msg.sender) in balances. Finally, the value of the key(_to) increases.
5.2 Analysis and vulnerabilities
At first glance, nothing strange is seen in the code. Analyzing the Solidity documentation:
int/uint: Signed and unsigned integers of various sizes. Keywords uint8 to uint256 in steps of 8 (unsigned of 8 up to 256 bits) and int8 to int256. uint and int are aliases for uint256 and int256, respectively.
Balance is a dictionary that links an address as a key and an unsigned integer as a value, and also _value is an unsigned integer. Therefore, the subtract between two unsigned integers, will be another unsigned integer (always greater than zero). This situation completely nullifies the require clause.
require(balances[msg.sender] - _value >= 0);
balances[msg.sender] -= _value;Seeing the second line, the same reasoning is made: if the contract starts with an _initialSupply of twenty tokens and twenty-one tokens are subtracted, an underflow will happen. An unsigned integer can not take a negative value (20 - 21 = -1). According to Solidity documentation: signed and unsigned integers are by default 256 bits long. In other words: the range is 2 elevated to 256 - 1. Therefore, if an underflow will happen with a negative number, the result will be 255.
Example of eight-bit overflow
In this case, if 21 tokens will be transferred, instead of output an error for 'lack of funds', 255 will have.