git clone https://github.com/PurrProof/ethernaut-solved.git
cd ethernaut-solved
git submodule update --init
cp .env.example .env
pnpm it
const prov = new _ethers.providers.Web3Provider(window.ethereum);
await prov.getStorageAt(await contract.address,2)
sol2uml storage -d -u $RPC_NODE_URL -c Privacy -s $PRIVACY_INSTANCE_ADDRESS -o storage.svg ./Privacy.sol
cast send -i -r $RPC_NODE_URL --create $BYTECODE
cast call -i -r $RPC_NODE_URL $ADDRESS $FUNCTION_ID
- perform list of view/pure functions calls
- submit read password to authenticate() function
- call levelInstance().contribute({value:1})
- transfer to contract 1 wei, it will trigger receive() function, which will transfer ownership to sender
- call levelInstance().withdraw() to withdraw all funds
- to claim ownership, just call instance.Fallout() function (which is not a contstructor)
- repeat coin flip logic in the attacker contract
- make 10 guesses, one per block
- tx.origin != msg.sender: call target contract through proxxy (attacker) contract
Attack vector
- underflow in the transfer() function
How to avoid
- use solidity 0.8.0+, there is a checked arithmetics by default
- use libraries like the SafeMath for the older solidity versions
Attack vector
Send payload=Delegate.pwn.selector
to the Delegation
contract. The call triggers the fallback()
function, which
delegates execution to the Delegate
contract, where the owner storage variable is changed to msg.sender
. Since the
call is executed in the context of Delegation
, its owner
storage variable, located in the 0th slot, is the one that
gets changed.
How to avoid
Secure the fallback
function with access control or avoid using delegatecall
in it. Explicitly define functions to
prevent unauthorized state changes.
- the EVM doesn't prevent self destructing contract from sending funds to either EOA or to SCA
- read password from contract storage (1st slot)
- attacker contract should have no payable receive/fallback functions
- send prize + 1 value from attacker contract to target contract
- in single tx: donate amount, withdraw amount, re-enter target in receive() and withdraw(1), causing underflow of attacker balance in mapping
- deplete target balance in same(or another) tx by calling target.withdraw(target.balance)
- key is to make Bulding.isLastFloor(...) function which gives different results depends on input data and target's state
- code is in the last array item, which is situated at the slot #5. Read this slot contents, take upper 16 bytes, that's the password
- call
instance.unlock(password)
- to vizualize level storage, install
sol2uml
, then runsol2uml storage -d -u $RPC_NODE_URL -c Privacy -s $PRIVACY_INSTANCE_ADDRESS -o storage.svg ./Privacy.sol
- gasleft() may change because of compiler version and settings, so bruteforce
- for code, use 2 lower bytes of tx/origin, and upper 32 bits should not be zero
- victim.enter(...) function should be called in attacker constructor; this way victim.extcodesize(attacker) will be still zero
- the idea behind _gateKey construction is that val XOR (NOT val) => all bits set
- it's just as simple as
token.connect(player).approve(other, totalAmount)
, thentoken.connect(other).transferFrom(player, other. totalAmount)
- call second library, it will overwrite 0th slot in storage with address of fake library
- call first library, faked by us: it will overwrite slots 0-2 in storage, where slot #2 contains owner address
- contract addresses are deterministic:
new address = keccak256(creatorAddress, nonce)
, where nonce starts from 0 for EOAs, and from 1 for SCAs, in latter case nonce means number of spawned contracts
18. Magic Number. Level, solution: 1 raw bytecode, 2 assembly
// init code
PUSH1 0x0a // sizecopy, 10 bytes (decimal) is size of runtime code
PUSH1 0x0c // offset, 13 bytes (decimal) is size of init code
PUSH1 0x00 // destOffset, target offset in memory
CODECOPY // destOffset, offset, sizecopy => runtime bytecode into memory
PUSH1 0x0a // size, 10 bytes (decimal) is size of runtime code
PUSH1 0x00 // offset
RETURN // offset, size => halt execution, return data from memory
// run time code
PUSH1 0x2a // value, 42 decimal
PUSH1 0x00 // offset
MSTORE // offset, value => save word (32 bytes) to memory
PUSH1 0x20 // size
PUSH1 0x00 // offset
RETURN // offset, size => halt execution, return data (32 bytes here) from memory
Proposed level description improvement: pull-request
- investigate storage structure, using contract ABI and getStorage() function
- optionally, using contract.record(_content), check that codex[] takes slot#1, it should store array length
- see
0.6.0 breaking changes,
Member-access to length of arrays is now always read-only, even for storage arrays. It is no longer possible to resize storage arrays by assigning a new value to their length.
- underflow array length by calling retract()
- calculate shift to address slot #0, it equals to type(uint256).max - keccak256(1) + 1, where keccak256(1) is address of slot, containing 0th array element
- fill the slot #0 with new owner address with help of revise(i, owner)
Attack vector
- the "partner" contract spend all available to it gas (63/64 of total in parent call) in infinite cycle
- the rest 1/64 gas is not enough to make .transfer()
How to avoid
- limit gas for external calls, like .call{gas:N}("")
- follow Check-Effects-Iteration pattern
Attack vector
- attacker contract fake its responses depending on target contract state
How to avoid
- don't trust external/untrusted contracts output
Current DEX works this way:
User action | DexT1 | DexT2 | UserT1 | UserT2 |
---|---|---|---|---|
Initial | 100 | 10 | 10 | 10 |
10 T1 -> T2 | 110 | 90 | 0 | 20 |
20 T2 -> T1 | 86 | 110 | 24 | 0 |
24 T1 -> T2 | 110 | 80 | 0 | 30 |
30 T2 -> T1 | 69 | 110 | 41 | 0 |
41 T1 -> T2 | 110 | 45 | 0 | 65 |
45 T2 -> T1 | 0 | 90 | 110 | 20 |
I'd rather use constant product formula
Attack vector
- attacker swaps self-managed tokens for tokens, registered in the dex
How to avoid
- don't allow to swap not registered / not trusted tokens
P.S. I made too honest fake tokens ;) The original solution is more brutal — their fake tokens only have the balanceOf and transferFrom functions, which return just the necessary minimum.
-
Attack Vector
- Storage Collision Exploit
Exploits a storage collision between the proxy'spendingAdmin
and the implementation'sowner
to gain control. - Recursive Multicall Exploit
Usesmulticall
with a reentrant-like call to drain funds by callingdeposit
twice in one transaction. - Admin Privilege Hijack
Overwrites the proxy'sadmin
by setting themaxBalance
, due to storage collision, to take control.
- Storage Collision Exploit
-
How to Avoid
- Proper Storage Layout
Use reserved storage slots to avoid collisions between proxy and implementation contracts. - Secure Delegatecalls
Only delegatecall to trusted and verified implementations with compatible storage. - Restrict Function Combinations
Limitmulticall
or prevent repeated calls to functions likedeposit
within the same transaction.
- Proper Storage Layout
Attack vector
- Take upgrader role of implementation contract (Engine). It's possible because initialize() function is not disabled and opened to everyone.
- Change Engine's implementation to attacker contract, then call attacker's method, which contains selfdestruct()
How to avoid
- disable initializer in Engine contract
P.S. discussion
26. Double Entry Point. Level, solution: detection bot, test
Attack vector
- revert with NotEnoughBalance() error in the attacker's contract notify() function
- error will be bubbled up to GoodSamaritan contract, where rest of balance will be transfered to attacker contract
- don't revert, if transfer exceeds 10 coins
How to avoid
- assume, that errors may be bubbled up by any contract down in the chain
Attack vector
- use block.timestamp as password; it's same for all internal transactions within transaction
Attack vector
Manually created calldata with non-standard variable offset.
Normal Call: Switch.flipSwitch(abi.encodeWithSignature("turnSwitchOn()"))
Calldata:
0x30c13ade: selector of flipSwitch(bytes)
0x0000000000000000000000000000000000000000000000000000000000000020: offset to the `bytes` parameter area
0x0000000000000000000000000000000000000000000000000000000000000004: length of the `bytes` parameter, 4 in this case
0x76227e1200000000000000000000000000000000000000000000000000000000: data itself, right-padded
That's why offset 68 is hardcoded in the expression `calldatacopy(selector, 68, 4) // grab function selector from calldata`
I.e. 4 (selector) + 32 (offset) + 32 (length) = 68
So we need to have selector of the allowed function, i.e. Switch.turnSwitchOff.selector, at the offset 68
We'll construct calldata manually:
0x30c13ade: selector of flipSwitch(bytes)
0x0000000000000000000000000000000000000000000000000000000000000060: offset to the `bytes` parameter area
0x0000000000000000000000000000000000000000000000000000000000000000: not used word, it can be anything
0x20606e1500000000000000000000000000000000000000000000000000000000: hardcoded Switch.turnSwitchOff.selector
0x0000000000000000000000000000000000000000000000000000000000000004: length of flipSwitch's bytes memory _data parameter
0x76227e12: data itself, Switch.turnSwitchOn.selector
How to avoid
- don't hardcode variable offsets when dealing with calldata at low level
https://docs.soliditylang.org/en/latest/security-considerations.html#minor-details
Types that do not occupy the full 32 bytes might contain “dirty higher order bits”. This is especially important if you access msg.data - it poses a malleability risk: You can craft transactions that call a function f(uint8 x) with a raw byte argument of 0xff000001 and with 0x00000001. Both are fed to the contract and both will look like the number 1 as far as x is concerned, but msg.data will be different, so if you use keccak256(msg.data) for anything, you will get different results.
ethereum/solidity#14766 (closed)
This no longer seems to be true in Solidity >= 0.8. ABI decoding now reverts when it encounters dirty high-order bits. Can you please confirm this is no longer an issue and add a comment to the documentation (or remove this section), or clarify why this is still an issue in Solidity >= 0.8?
Attack Vector
Exploit a bug in Stake.StakeWETH()
: the return value of the low-level WETH.transfer()
call isn't checked, though it
can return false
.
How to Avoid
- Always check return values of external
ERC-20
token calls. - Use OpenZeppelin's
SafeERC20
wrappers for saferERC20
function calls.