25882 - [SC - Insight] Freezing of funds from the Default Deposit Cont...
Submitted on Nov 20th 2023 at 22:25:24 UTC by @infosec_us_team for Boost | DeGate
Report ID: #25882
Report type: Smart Contract
Report severity: Insight
Target: https://etherscan.io/address/0x9C07A72177c5A05410cA338823e790876E79D73B#code
Impacts:
Permanent freezing of funds from the Default Deposit Contract that requires malicious actions from the DeGate Operator.
Description
Background
DeGate's guarantee self custody of their assets with the Exodus Mode and escaping censorship via forced withdrawals.
The request must be served within a defined time period. If this does not happen, the system will halt regular operation and permit trustless withdrawal of funds.
Bug summary
Our team discovered an attack vector that allows a malicious operator to censor a target and also freeze/seize his funds at a low cost. The victim is unable to request a forced withdraw, and the exchange will not get into Exodus Mode.
The attack costs up to 0.02 ETH ($40) every 30 days with the funds seized, per victim. It is a low cost, the victims could be whales with a substantial amount of assets deposited, the malicious operator would ask for a fee to release their funds.
With a cost lower than $500 a malicious node operator can seize for an entire year all the deposited funds of a specific user.
In the "Permanent Fix" section of this report we'll share how adding 2 lines of code to forceWithdraw(...)
, prevent this and other types of attack vectors from happening, and additionally make the forceWithdraw(...)
function 100% trustless and censorship resistant.
Attack vector
In ExchangeV3.sol
the forceWithdraw(...)
function accepts an arbitrary accountID
and will store the forceWithdraw request in:
The next time the function forceWithdraw(...)
is called with the same accountID
and tokenID
will revert if the pending request has not been processed by the operator.
Here's how this can be abused:
Step 1- Operator censors a whale and stops processing his orders.
Step 2- Whale tries to exit the exchange by sending a forceWithdraw(...)
request, but the operator front-runs his transaction and he sends the forceWithdraw(...)
using the whale's accountID
and tokenID
. Whale's transaction will fail.
Step 3- The operator can now wait 14 days, and after that, include the pending forced withdraw in a block and submit it to L1 to prevent the exchange from entering a Exodus Mode.
Step 4- Because the address of the owner for that accountID
(the victim's address) is different from the address that initiated the pending forced withdraw (operator's address) the pending forced withdraw will be invalidated and removed from the storage:
Snippet of code extracted from WithdrawTransaction.sol, function
process(...)
, used in ExchangeV3.sol to process withdrawals.
Step 5- If now, the victim decides to submit another forceWithdraw(...)
request, the operator can front-run him again to freeze his funds for another 14 days, and process the pending request just before it expires.
If by any chance, all other users in the protocol decide to submit force exit requests, the operator can process them as normal and let them go, but leave the whale's funds there, seized forever.
To 100% guarantee self-custody of funds, escape censorship and forbid a malicious node or a griefer from submitting invalid forceWithdraw requests with our accountID
to prevent us from withdrawing our funds, read the next section.
Permanent fix
DeGate's documentation explains that the reason any accountID
is accepted, and the validity of the request is checked offline instead of onchain, is because the ExchangeV3.sol
smart contract doesn't know who the owner
of a specific accountID
is.
There's an elegant and simple solution to know if msg.sender
is the owner of the accountID
or the Agent of the owner, leading to always generating valid forceWithdraw
pending requests, and permanently stopping this function from being front-run.
In the extraordinary event where a user its been censored and he wants to send a request to exit the exchange in a trustless, unstoppable manner, he can submit a forcedWithdrawal request with MerkleProof (similar to what users already have to do in the withdrawFromMerkleTree(...)
function), and ExchangeV3.sol
will calculate the MerkleRoot using msg.sender
as the value for the merkleProof.accountLeaf.owner
variable.
Now, the only way the forcedWithdrawal(...)
transaction can succeed, is if msg.sender
is equal to the owner
of the accountID
or the Agent of the account.
There's no way to maliciously bypass this check.
This is an example in pseudo code for the sake of simplicity (we share the full code for the production-ready solution later on):
This can only succeed if the caller (msg.sender
) passed the correct accountID
.
Now - examples aside - here's the final implementation for a forceWithdraw(...)
function that is 100% trustless and censorship resistant:
In
ExchangeV3.sol
:
In the
ExchangeWithdrawals.sol
library:
Proof of concept
A proof of concept demonstrating how a forced withdrawal requested from an incorrect owner will be ignored, is in the testExchangeDepositWithdraw.ts
test file. Here's the test:
Last updated