Writeup by SunSec
Condition:
flashLoan
function unable to work.Key Concepts:
Solution:
totalSupply != balanceBefore
, causing the flash loan to fail.Conditions:
vm.getNonce(player)
is less than or equal to 2.weth.balanceOf(address(receiver))
equals 0.weth.balanceOf(address(pool))
equals 0.weth.balanceOf(recovery)
equals WETH_IN_POOL + WETH_IN_RECEIVER = 1010 ETH
.Key Concepts:
msg.data
(calldata manipulation)Solution:
NaiveReceiverPool
inherits Multicall
and IERC3156FlashLender
.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 that onFlashLoan
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 from FlashLoanReceiver
. But the problem requires that the Nonce must be less than 2. As mentioned earlier, NaiveReceiverPool
inherits Multicall
, so we can use Multicall
to perform 10 flash loan operations in a single transaction, thus satisfying the Nonce requirement of being less than 2.NaiveReceiverPool
. From the contract, we can see that the only function that can transfer the assets is withdraw
. It can be noticed that _msgSender
needs to satisfy msg.sender == trustedForwarder && msg.data.length >= 20
to return the last 20 bytes of the address, which can be manipulated.msg.sender == trustedForwarder
, we need to use a forwarder to execute a meta-transaction.POC :
Conditions:
recovery
accountKey Concepts:
Solution:
flashLoan
, we can see target.functionCall(data);
which allows executing arbitrary calldata, and the target address is controllable. Therefore, arbitrary instructions can be executed directly.POC :
Conditions:
Recovery
wallet must equal the original amount of ETH in the pool (i.e., ETHER_IN_POOL
).Key Concepts:
address(this).balance
as a validation methodSolution:
flashLoan
uses a non-standard approach, where it checks if the loan is repaid simply by comparing the pool’s balance if (address(this).balance < balanceBefore)
.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 a withdraw
and transfer the funds out.POC :
Conditions:
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.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:
Solution:
claimRewards
, the update to whether a user has claimed rewards is done through _setClaimed()
.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.player
's address has an index of 188.POC :
Conditions:
Recovery
wallet must equal the original amount of DVT in the pool (i.e., TOKENS_IN_POOL
).Key Concepts:
delegate
)Solution:
SelfiePool
contract has a function emergencyExit()
that can transfer all the contract's balance, but it requires onlyGovernance
permission.SimpleGovernance
contract, it is possible to initiate a proposal through queueAction
, and the data can be controlled. This allows us to execute emergencyExit()
via this method.queueAction
, it must pass the _hasEnoughVotes
check. Since DamnValuableVotes
inherits ERC20Votes
, 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.queueAction
-> repay -> execute executeAction
POC :
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:
exchange
contract address must be 0.recovery
address must equal the initial ETH balance of the exchange
.INITIAL_NFT_PRICE
), ensuring no price manipulation during the challenge.Key Concepts:
Solution:
POC :
Conditions:
lendingPool
must be 0.recovery
wallet.Key Concepts:
balanceOf
as a reference for pricing.Solution:
PuppetPool
, we can see that _computeOraclePrice
uses the balance to calculate the oracle price.PuppetPool
, we can see that _computeOraclePrice
uses the balance to calculate the oracle price.uniswapV1Exchange
via tokenToEthTransferInput
to manipulate the price.POC :
Conditions:
lendingPool
must be 0.recovery
wallet.Key Concepts:
getReserves
as a reference for pricing.Solution:
getReserves
is similar to fetching the balance, which poses a risk of manipulation.POC :
Conditions:
recoveryManager
smart contract and transferred to the recoveryManagerOwner
address.offersCount()
should be 0.Key Concepts:
mas.value
in an arraySolution:
_buyOne
function for purchasing NFTs, there is an error in checking the payment amount. As long as msg.value
is greater than priceToPay
, the transaction can proceed.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._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.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.FreeRiderRecoveryManager
to collect the 45 ETH bounty. REFPOC :
Conditions:
Key Concepts:
Solution:
WalletRegistry
by creating a wallet. The proxyCreated
function notes that the wallet is created through a proxy. SafeProxyFactory::createProxyWithCallback, you can see code below.deployProxy
is executed, and we can control it through call(gas(), proxy, 0, add(initializer, 0x20), mload(initializer), 0, 0), 0)
. So, within the initializer, we can execute Safe.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.POC :
Conditions:
Key Concepts:
Solution:
schedule
should be called first, followed by a time delay (Timelock), and finally, the operations are executed through execute
. However, there is a logical flaw in the execute()
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 the getOperationState(id)
check before executing the operation, ensuring that only legitimate and scheduled operations can be executed.schedule
to update the state.grantRole
to acquire PROPOSER_ROLE
-> update delay
to 0 -> transferOwnership
-> timelockSchedule
-> upgrade the contract -> withdraw -> done.POC :
Conditions:
Factory
contract must have code.walletDeployer.cpy()
contains code.USER_DEPOSIT_ADDRESS
must have code present.DEPOSIT_TOKEN_AMOUNT
.ward
) token balance matches the initial balance of the walletDeployer
contract, indicating that the player has transferred the required funds to the guardian.Key Concepts:
Solution:
computeCreate2Address
, calculate the USER_DEPOSIT_ADDRESS
, which gives a nonce of 13. Then, through the challenge's walletDeployer.drop()
, use createProxyWithNonce
to create the user's Safe wallet.AuthorizerUpgradeable
contract occupies slot0
with needsInit
, leading to a storage collision. We can initialize the user's wallet and change the guardian (ward
) to ourselves, receiving 1 ETH.POC :
Conditions:
block.timestamp - initialBlockTimestamp < 115
seconds.LENDING_POOL_INITIAL_TOKEN_BALANCE
tokens must be transferred to the recovery wallet.Key Concepts:
Solution:
calculateDepositOfWETHRequired
will be three times higher.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.POC :
Conditions:
VAULT_TOKEN_BALANCE
tokens must be transferred to the recovery wallet.Key Concepts:
Solution:
AuthorizedExecutor.execute()
, calldataload
is used to extract 4 bytes of the function selector from the provided actionData
starting at the calldataOffset
(100 bytes) and then checks whether this ID is authorized using getActionId
.deployer
can execute sweepFunds
with the selector 0x85fb709d
, and the player
can execute withdraw
with the selector 0xd9caed12
.getActionId
check, which allows arbitrary execution of functionCall
.execute()
function, actionData
is a dynamically sized bytes
parameter.0x80
is an offset that points to the starting position of the actual data in actionData
. This offset is calculated relative to the start of the entire calldata. So in this case, it's 0x80
.POC :
Conditions:
missingTokens
) in the marketplace must be greater than 0.01% of initialTokensInMarketplace
.Key Concepts:
mulDivDown
rounds down to 0.Solution:
fill()
, it is discovered that want.mulDivDown(_toDVT(offer.price, _currentRate), offer.totalShards)
calculates the number of shards a buyer can purchase based on want
. However, the calculation in this function may experience underflows or calculation errors, especially with the combination of mulDivDown
and _toDVT
. This algorithm causes the final result to be 0 when want
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 of want
that can result in a 0-price purchase is 133.cancel()
to return the shards to the marketplace, and at this point, you will receive DVT tokens.POC :
Conditions:
Key Concepts:
Solution:
if (collateralValue >= borrowValue) revert HealthyPosition(borrowValue, collateralValue);
must be satisfied.Conditions:
counter()
value of the L1 Gateway must be greater than or equal to WITHDRAWALS_AMOUNT
, indicating that a sufficient number of withdrawals have been completed.Key Concepts:
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.Solution:
withdrawals.json
, which contains the logs of four MessageStored
events sent from L2 to L1.
MessageStored
is 0x43738d03
, obtained from keccak256("MessageStored(bytes32,uint256,address,address,uint256,bytes)")
.data
field to understand the operations inside.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 using L1Gateway.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 :