Can smart contract hacking tools hack CTFs ?
In my last article about hacking tools, I highlighted that they are pretty good to detect obvious flaws like reentrancy/integer overflow/unprotected ether withdrawal.
But these flaws are very easy, every solidity dev is able to see them in less than a glance. Now we will test using more harder smart contracts.
Rules
We will use the same 4 tools as in the previous episode:
- Slither.
- Mythix.
- Mythril.
- Remix static analysis plugin.
They are quite easy to install.
The smart contracts of the following CTFs, will be used to test our tools:
- Capture the ether
- DWVA DEFI
- Ethernaut
If the tool finds exactly the critical flaw, he gets 1 point.
If the tool finds something suspicious, he gets 0.5 point.
If the tool doesn’t find the critical flaw, he gets 0 point.
Now, we are ready ! Let’s GO !
ROUND 1 CTF: Mathematical flaw
Source : https://ethernaut.openzeppelin.com/level/0xC084FC117324D7C628dBC41F17CAcAaF4765f49e
(All the highlighted code, are the flaw)
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import '@openzeppelin/contracts/math/SafeMath.sol';
import '@openzeppelin/contracts/access/Ownable.sol';
contract Dex is Ownable {
using SafeMath for uint;
address public token1;
address public token2;
constructor() public {}
function setTokens(address _token1, address _token2) public onlyOwner {
token1 = _token1;
token2 = _token2;
}
function addLiquidity(address token_address, uint amount) public onlyOwner {
IERC20(token_address).transferFrom(msg.sender, address(this), amount);
}
function swap(address from, address to, uint amount) public {
require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
uint swapAmount = getSwapPrice(from, to, amount);
IERC20(from).transferFrom(msg.sender, address(this), amount);
IERC20(to).approve(address(this), swapAmount);
IERC20(to).transferFrom(address(this), msg.sender, swapAmount);
}
function getSwapPrice(address from, address to, uint amount) public view returns(uint){
return((amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this)));
}
function approve(address spender, uint amount) public {
SwappableToken(token1).approve(msg.sender, spender, amount);
SwappableToken(token2).approve(msg.sender, spender, amount);
}
function balanceOf(address token, address account) public view returns (uint){
return IERC20(token).balanceOf(account);
}
}
contract SwappableToken is ERC20 {
address private _dex;
constructor(address dexInstance, string memory name, string memory symbol, uint256 initialSupply) public ERC20(name, symbol) {
_mint(msg.sender, initialSupply);
_dex = dexInstance;
}
function approve(address owner, address spender, uint256 amount) public returns(bool){
require(owner != _dex, "InvalidApprover");
super._approve(owner, spender, amount);
}
}
The player have 10 of token A and B, the contract (DEX) has 100 of A and B.
The goal is to drain the DEX of either the token A or the token B.
The flaw is this contract is quite simple. In the highlighted line of code, there is a “mathematical vulnerability” which will allow the player to swap the token in a way that he may get more token than before.
Can our tools spot the flaws?
Results
- Slither (+0) Finding : found an uncheked transfers, this is not the flaw…
- Mythril (+0) Finding : found an underflow in “increase allowance”, but this is not the main flaw
- Mythix (+0.25) Finding : found an overflow is swap(), but it’s not the main flaw.
- Remix (+0) Findings : Nothing found (besides a false positive reentrancy..)
ROUND 2 CTF: Predict the block hash
Source: https://capturetheether.com/challenges/lotteries/predict-the-block-hash/
pragma solidity ^0.4.21;
contract PredictTheBlockHashChallenge {
address guesser;
bytes32 guess;
uint256 settlementBlockNumber;
function PredictTheBlockHashChallenge() public payable {
require(msg.value == 1 ether);
}
function isComplete() public view returns (bool) {
return address(this).balance == 0;
}
function lockInGuess(bytes32 hash) public payable {
require(guesser == 0);
require(msg.value == 1 ether);
guesser = msg.sender;
guess = hash;
settlementBlockNumber = block.number + 1;
}
function settle() public {
require(msg.sender == guesser);
require(block.number > settlementBlockNumber);
bytes32 answer = block.blockhash(settlementBlockNumber);
guesser = 0;
if (guess == answer) {
msg.sender.transfer(2 ether);
}
}
}
This challenge is a bit harder to explain but basically:
- I call the lockInGuess function and i supply a byte32 value and 1 ether
The byte32 value is written to the storage. - I call the settle() function.
block.blockhash() is calculated and compared to our previously provided value.
If it’s false i have to retry in the beginning by calling lockInGuess() again.
But…
blockk.blockhash returns the bloc hash only in the current block is superior to at most 256 otherwise, if ask to retrieve an older block hash (older than 256 block which equals about to 50 minutes) then it will return 0x000..000.
So the answer is the provide 0x000.000, to wait 257 blocks and call the settle() function.
- Slither (+0.25) Finding : PredictTheBlockHashChallenge.settle() (2.sol#25–35) uses a dangerous strict equality:
guess == answer (OK but doesn’t help to solve the challenge) - Mythril (+0.25) Finding : Potential use of “block.number” as source of randonmness. (OK but doesn’t help to solve the challenge)
- Mythix (+0.25) Finding : A control flow decision is made based on a predictable variable. (same as the 2 firsts)
- Remix (+0.75) Findings : Use of “blockhash”: “blockhash(uint blockNumber)” is used to access the last 256 block hashes, NICE!!! remix almost found the flaw!
ROUND 3 CTF : Private Variable
Source : https://ethernaut.openzeppelin.com/level/0xf94b476063B6379A3c8b6C836efB8B3e10eDe188
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract Vault {
bool public locked;
bytes32 private password;
constructor(bytes32 _password) public {
locked = true;
password = _password;
}
function unlock(bytes32 _password) public {
if (password == _password) {
locked = false;
}
}
}
The goal of this challenge is to read the key variable in storage which is private. Fortunately, for us the “vulnerability” is very simple, a private variable in the blockchain doesn’t mean you can’t read it.
You just can’t access it inside another smart contract, but you can use web3.getStorageAt(address,slot) to get the private value and pass the challenge.
In this case, we need to set lock to false in order to pass the challenge.
- Slither (+0) Finding : Nothing found
- Mythril (+0) Finding : Nothing found.
- Mythix (+0) Finding : Nothing found.
- Remix (+0) Findings : Nothing found.
This one were harder for our tools because they can’t understand the purpose of the code, that’s why they are very limited.
ROUND 4 CTF : flash-loan
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/Address.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";/**
* @title TrusterLenderPool
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/contract TrusterLenderPool is ReentrancyGuard {
using Address for address;
IERC20 public immutable damnValuableToken;
constructor (address tokenAddress) {
damnValuableToken = IERC20(tokenAddress);
} function flashLoan(uint256 borrowAmount,address borrower,address target,bytes calldata data) external nonReentrant {
uint256 balanceBefore = damnValuableToken.balanceOf(address(this));
require(balanceBefore >= borrowAmount, "Not enough tokens in pool");
damnValuableToken.transfer(borrower, borrowAmount);
target.functionCall(data);
uint256 balanceAfter = damnValuableToken.balanceOf(address(this));
require(balanceAfter >= balanceBefore, "Flash loan hasn't been paid back");
}}
The smart contract is a lending pool with 1 million of tokens, but we have nothing, the goal is to drain the pool.
We see that the pool can call any contract (with data) we supplied (officially to do the flash loan). So in this case we can approve the player (Us !) on the token with the context of the contract.
After that, we can drain the funds of the smart contract. This one is quite easy and is more a “gift.”
- Slither (+0) Finding : Same as remix
- Mythril (+0.5) Finding : possible call to arbitrary smart contract (Not bad…)
- Mythix (+ 0) Finding : can’t run the smart contract lol. (after 45 min of trying i decided to give up ^^)
- Remix (+0) Findings : Use of “call”: should be avoided whenever possible.It can lead to unexpected behavior if return value is not handled properly. NOPE that doesn’t count as a finding
Results Summary
Let’s summarize the result:
- Slither (Total: +0.25/4)
- Mythix (Total: +0.75/4)
- Mythril (Total: +0.5/4)
- Remix (Total: +0.75/4)
The Winner is:
Conclusion
So, should you rely on a simple tool in order to protect your smart contract?
Of course not, these smart contracts are quite simple and even the best tools are struggling. What if they should audit 1500 code lines derived from 4 smart contracts?
They can at most help you to remove obvious flaws you may have missed cause of inattention but merely more.
So the best thing to do, is a deep review by hand :)