Skip to content

WOETH: Donation attack prevention #2106

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 51 commits into
base: master
Choose a base branch
from

Conversation

sparrowDom
Copy link
Member

@sparrowDom sparrowDom commented Jun 21, 2024

Overview

This PR prevents an attacker to manipulate the exchange rate between WOETH & OETH by donating OETH to the contract .

Code Change Checklist

To be completed before internal review begins:

  • The contract code is complete
  • Executable deployment file
  • Fork tests that test after the deployment file runs (done with a brownie script to confirm that existing WOETH balances are not affected)
  • Unit tests *if needed
  • The owner has done a full checklist review of the code + tests

Internal review:

  • Two approvals by internal reviewers

Copy link

github-actions bot commented Jun 21, 2024

Warnings
⚠️ 👀 This PR needs at least 2 reviewers

Generated by 🚫 dangerJS against 79af39f

Copy link

codecov bot commented Jun 21, 2024

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 53.85%. Comparing base (fa077cd) to head (f0580bc).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2106      +/-   ##
==========================================
+ Coverage   53.26%   53.85%   +0.58%     
==========================================
  Files          79       79              
  Lines        4098     4120      +22     
  Branches     1079     1081       +2     
==========================================
+ Hits         2183     2219      +36     
+ Misses       1912     1898      -14     
  Partials        3        3              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@sparrowDom sparrowDom marked this pull request as ready for review June 26, 2024 08:44
@sparrowDom
Copy link
Member Author

sparrowDom commented Jun 26, 2024

Requirements

What is the PR trying to do? Is this the right thing? Are there bugs in the requirements?
No

Easy Checks

Authentication

  • Never use tx.origin
  • Every external/public function is supposed to be externally accessible
  • Every external/public function has the correct authentication

Ethereum

  • Contract does not send or receive Ethereum.
  • Contract has no payable methods.
  • Contract is not vulnerable to being sent self destruct ETH

Cryptographic code

  • This contract code does not roll it's own crypto.
  • No signature checks without reverting on a 0x00 result.
  • No signed data could be used in a replay attack, on our contract or others.

Gas problems

  • Contracts with for loops must have either: no loops
    • A way to remove items no loops
    • Can be upgraded to get unstuck no loops
    • Size can only controlled by admins no loops
  • Contracts with for loops must not allow end users to add unlimited items to a loop that is used by others or admins. no loops

Black magic

  • Does not contain selfdestruct
  • Does not use delegatecall outside of proxying. If an implementation contract were to call delegatecall under attacker control, it could call selfdestruct the implementation contract, leading to calls through the proxy silently succeeding, even though they were failing.
  • Address.isContract should be treated as if could return anything at any time, because that's reality.

Overflow

  • Code is solidity version >= 0.8.0
  • [ ] All for loops use uint256 no loops

Proxy

  • No storage variable initialized at definition when contract used as a proxy implementation.

Events

  • All state changing functions emit events

Medium Checks

Rounding

  • Contract rounds in the protocols favor it doesn't but it mimics what OETH is doing using mulTruncateon high resolution credits and high resolution credits per token. That should ensure the correct rounding of the values
  • Contract does not have bugs from loosing rounding precision
  • Code correctly multiplies before division
  • Contract does not have bugs from zero or near zero amounts

Dependencies

  • [ ] Review any new contract dependencies thoroughly (e.g. OpenZeppelin imports) when new dependencies are added or version of dependencies changes. no new dependancies added
  • If OpenZeppelin ACL roles are use review & enumerate all of them.
  • Check OpenZeppelin security vulnerabilities and see if any apply to current PR considering the version of OpenZeppelin contract used.

External calls

  • Contract addresses passed in are validated
  • No unsafe external calls
  • Reentrancy guards on all state changing functions_no need we only deal with our own OETH - trusted contract_
    • Still doesn't protect against external contracts changing the state of the world if they are called.
  • No malicious behaviors
  • Low level call() must require success.
  • No slippage attacks (we need to validate expected tokens received)
  • Oracles, one of:
    • No oracles
    • Oracles can't be bent
    • If oracle can be bent, it won't hurt us.
  • Do not call balanceOf for external contracts to determine what they will do when they use internal accounting

Tests

  • Each publicly callable method has a test
  • Each logical branch has a test
  • Each require() has a test
  • Edge conditions are tested
  • If tests interact with AMM make sure enough edge cases (pool tilts) are tested. Ideally with fuzzing. _ it doesn't_

Deploy

  • Deployer permissions are removed after deploy

Thinking

Logic

Are there bugs in the logic?

  • Correct usage of global & local variables. -> they might differentiate only by an underscore that can be overlooked (e.g. address vs _address).

Deployment Considerations

Are there things that must be done on deploy, or in the wider ecosystem for this code to work. Are they done?
Nothing special needs to happen on deploy

Internal State

  • What can be always said about relationships between stored state
  • What must hold true about state before a function can run correctly (preconditions)
  • What must hold true about the return or any changes to state after a function has run.

For all 3 questions above it is important that: The internal credits stored in WOETH and stored in OETH (for WOETH contract) should always match unless someone sends extra OETH to the WOETH contract manually.

Does this code do that?
Yes

Attack

What could the impacts of code failure in this code be.
Incorrect OETH balance tracking within WOETH contract. Which would result in incorrect pricing of WOETH.

What conditions could cause this code to fail if they were not true.
Math tracking credits within WOETH differentiating from the one in OETH.

Does this code successfully block all attacks.
yes

Flavor

Could this code be simpler?
No
Could this code be less vulnerable to other code behaving weirdly?
No

@sparrowDom sparrowDom requested a review from DanielVF as a code owner June 26, 2024 15:45
@DanielVF
Copy link
Collaborator

The core attack we are trying to stop is someone sending the OETH to the wOETH contract, causing the value of wOETH in OETH terms to go suddenly up.

It looks like totalAssets uses the amount of OETH held by the contract as one of two multipliers. totalAssets is in turn used to calculate the exchange ratio. If someone donates to the contract, one of these two multipliers goes up, and the donation has perfectly succeeded in increasing the value of each wOETH. This attack does not appear to be blocked at all?

Or am I missing something?

@DanielVF
Copy link
Collaborator

It also feels really scary that were are minting and burning using old ratios. That doesn't cause rektness?

Comment on lines 81 to 82
//@dev TODO: we could implement a feature where if anyone sends OETH direclty to
// the contract, that we can let the governor transfer the excess of the token.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: perhaps we could just treat any donation as "yield"?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While we shouldn't treat donations as instant yield (that's what this contract is trying to get away from), I do think we should build in a separate governor method to collect donated funds that are in excess of the backing funds.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably merge this PR in? #2119

@@ -31,11 +43,40 @@ contract WOETH is ERC4626, Governable, Initializable {
OETH(address(asset())).rebaseOptIn();
}

function name() public view virtual override returns (string memory) {
function initialize2() external onlyGovernor {
Copy link
Collaborator

@DanielVF DanielVF Feb 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we should have initialize() call initialize2(). This way new contract deploys don't need to call both, and we are less likely to make the bad mistake of not calling initialize2().

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great idea thanks: 5a7192d

uint256 woethAmount,
address receiver,
address owner
) public virtual override returns (uint256 oethAmount) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This contract does not currently compile. This should be fixed.

Also, although this is not the actual compile error, I'm wondering if these virtuals in these methods are wrong, since I think we want these functions callable without needing to be overridden by a child class.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes you are right these virtual keywords are not needed. We can add it in the future if need be: 5ff5c5d

* @return amount of OETH credits the OETH amount corresponds to
*/
function _oethToCredits(uint256 oethAmount) internal returns (uint256) {
(, uint256 creditsPerTokenHighres, ) = OETH(asset())
Copy link
Collaborator

@DanielVF DanielVF Feb 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both _oethToCredits() and totalAssets() call oeth.creditsBalanceOfHighres() to get the creditsPerToken, discarding the other values that function returns.

I think it makes more sense to call the simpler oeth.rebasingCreditsPerTokenHighres() instead.

There's two scenarios here:

  1. This wrapped token is correctly marked as rebasing. In this case, oeth.rebasingCreditsPerTokenHighres() will return the same value as what we are doing now, but be simpler, return only what we need, and cost less gas.

  2. Governance messes up the world in a bad way and turns off yield to the contract. The current call will immediately return 1e18 instead of 1e27ish, making for a really really wrong totalAssets, and thus really really wrong conversion rate, which would roughly speaking destroy the wrapped token and anything else using it. However, if oeth.rebasingCreditsPerTokenHighres() is used we'll only get a gradual drift off the correct value as expected yield does not come in.

In both cases the behavior of oeth.rebasingCreditsPerTokenHighres() seems better.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great comment and great points thanks: 3ef2219

address owner
) public virtual override returns (uint256 oethAmount) {
oethAmount = super.redeem(woethAmount, receiver, owner);
oethCreditsHighres -= _oethToCredits(oethAmount);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just a mathematical nit, and perhaps the code is okay without this, but in general, if you are depositing/plus-ing and withdrawing/minus-ing using the same conversion function, you are almost certainly rounding the wrong direction in one of them. It's possible we need two _oethToCredits, one that round up, and one that rounds down.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point I need to sleep on this one....

@DanielVF
Copy link
Collaborator

DanielVF commented Feb 26, 2025

🔴 Requirements

We want to make our non-rebasing wrapper contracts able to be safe to be borrowed on lending platforms, not just used as collateral.

There’s a little known attack in DeFi, where a wrapper token that can be donated to can be used in an attack. The details of this attack we will skip here, but it requires:

  1. Wrapper token that can be instantly redeemed for its base asset
  2. Wrapper token can have it’s exchange rate go up instantly from donations.
  3. Oracles that instantly update the true value of that wrapper token.
  4. There's not a lot of TVL in the wrapper yet, relative to lending platform configuration for the asset.
  5. That wrapper token can have unlimited borrowing.

My concern is that other coins who try to stop this attack, do so by slowly dripping out yield on their wrapped tokens. This means that flash loans are completely ineffective and an alternative slow approach allows a lending platforms to profitably liquidate anyone trying it.

This PR works by completely blocking donations to the wrapper contract, but donations to vault are passed through instantly. This still dilutes an attack, but only by the difference between the vault and wrapper token. This PR still increases the attack cost 2x-3x on our current coins, instead of the 7,000x cost increase with the yield drip approach.

Now a 2x-3x difficulty increase is certainly nice, and we get to a safe TVL size sooner, but it means that we still need to do the math on any particular lending platform to see if okay to be borrowed.

Circ in wrapper Wrapped Value Protocol Value 10% Attack Before 10% Attack After
OUSD 1.12% 100,762 9,010,263 10,076 901,026
OETH 43.15% 39,856,238 92,357,298 3,985,624 9,235,730
SuperOETHb 30.87% 13,891,111 45,000,000 1,389,111 4,500,000
OS 43.68% 11,260,398 25,780,243 1,126,040 2,578,024

I’m quite concerned that without the time drip, we only have a small band-aid, rather than a strong block.

@sparrowDom
Copy link
Member Author

sparrowDom commented Mar 3, 2025

Some additional info on the viability of inflation/donation attack. (for reference see xSushi Aave vulnerability incident report)

How the attack works

Before we are able to evaluate how much security a 2-3x increase in difficulty provides we should understand how the attack works:

  1. The attacker has 2 account A & B
  2. Account A takes a big Flashloan say 1b in ETH
  3. takes that eth and supplies it to a lending platform.
  4. Account A borrows as much WOETH as possible and sends it to account B
  5. Account B supplies that WOETH to the lending platform

Steps 4 & 5 are repeated as long as the Loan To Value amount on the lending platform allows it. Say it was possible to loop steps so many times that Account A owes 500m in WOETH and Account B has supplied all WOETH to the lending platform. To sum up:

Account A:

  • has borrowed 500m WOETH
  • has supplied 1b of ETH

Account B:

  • has supplied 500m WOETH
  1. Now the attacker send OETH to the WOETH contract, or WETH to the OETH Vault to inflate the exchange rate between OETH & WOETH. Since OETH TVL is 92m the attacker spends 184m to inflate the price of WOETH by 200% making 1 WOETH worth 3 OETH.
  2. Now the value of Account A's liabilities (WOETH) is 1.5b, while the tokens backing are only worth 1b. Account A is liquidated
  3. Account B now has 1.5b worth of collateral (in price inflated WOETH). It can borrow ETH to pay back the 500m flash loan, and 184m of OETH price increase. Everything else - 816m is profit. That is used to borrow any available assets on the lending platform.

Observations

  • looping of the wrapped token from account A to B is what multiplies the amount invested into donation attack to gained profits
  • the lending platform needs to have enough assets to borrow available to cover the expense of the donation attack and the initial flash loan
  • If a Wrapped token can be attacked directly it is easier to exploit. As the attack is much cheaper in that case: in the last step of looping wrapped tokens between A & B the account A can unwrap all of the wrapped token to minimise its TVL. This means that a direct donation to the wrapped token will be a lot cheaper.
  • Great props to @pandadefi and @DanielVF for pointing out that the donation attack on the Vault can still put lending platfroms at risk.

@sparrowDom sparrowDom mentioned this pull request Mar 17, 2025
6 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants