-
Notifications
You must be signed in to change notification settings - Fork 85
Simplified donation attack prevention #2453
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
Simplified donation attack prevention #2453
Conversation
Sonic deploy and basic tests along with cotract extending WOETH done here: 191f522 |
contracts/contracts/token/WOETH.sol
Outdated
@@ -111,10 +111,25 @@ contract WOETH is ERC4626, Governable, Initializable { | |||
IERC20(asset_).safeTransfer(governor(), amount_); | |||
} | |||
|
|||
/** | |||
* @dev See {IERC4262-convertToShares} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
⚪️ Lol, let's change both of these to the correct IERC4626
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
changed it to the natspec compliant @inheritdoc
: a2a69c9
contracts/contracts/token/WOETH.sol
Outdated
require(_adjuster == 0, "Initialize2 already called"); | ||
|
||
if (totalSupply() == 0) { | ||
_adjuster = 1e27; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another valid approach here would be that in case of 0 totalSupply
we could set the adjuster to rebasingCredits:
_adjuster = rebasingCreditsPerTokenHighres();
The difference in behaviour occurs if we deposit a fresh wrapped contract after rebases on the underlying token have already occurred. In case we set the adjuster to rebasingCreditsPerTokenHighres()
the WOETH -> OETH exchange rate would start at 1:1 and follow the underlying token's rebase rate.
If we keep it at 1e27
the exchange rate will follow the value accrual of OETH from inception.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I prefer the 1e27 approach - bigger number is more accuracy? Plus we'll be deploying this soon after launch on new tokens, so the end number should be very similar.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WOETH Audit
Requirements
We want to prevent donation attacks against lending protocols using our system. We want to prevent someone donating to this contract to instantly increase the exchange rate.
In this code, the exchange rates are directly tied to the OETH exchange rates without any consideration for the shares or assets on WOETH. Once initialized, there is no way to affect the exchange rate on this contract without going through the vault (which now has limits). This should meet our needs.
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
No crypto
Gas problems
No for loops
Black magic
No magic
Overflow
- Code is solidity version >= 0.8.0
Proxy
- No storage variable initialized at definition when contract used as a proxy implementation.
Events
- All state changing functions emit events
Medium Checks
Rounding and casts
- Contract rounds in the protocols favor
- 🟢 TotalAssets rounds down, which underestimates actual assets.
- Contract does not have bugs from loosing rounding precision
- 🟢 Have fork tested stupidly large OETH rebase ratios with precision loss, and WOETH errors in the correct direction.
- Code correctly multiplies before division
- Contract does not have bugs from zero or near zero amounts
- 🟢 When adjuster is zero, mints/deposits are disabled because of a divide by zero error. On new contracts there can be no supply until after adjuster is set.
- 🟢 Adjuster cannot be changed back to zero once it has a positive number, because of the require on the init.
- 🟣 If you were to migrate (initialize2) a contract with a very small totalSupply (say 1), and have donated some assets to it, you could force the contract into having big rounding errors. Ajuster errors favor deposits. Something like the classic first donation attack on 4626 contracts. However, we only have a few old wrapped tokens, and they already have lots of funds in them and will have sane adjustor values. New deploys are immune since they will go to a hardcoded 1e27.
- zero totalSupply() or zero totalAssets() does not affect exchange rates in or out.
- Safecast is aways used when casting
Dependencies
- Review any new contract dependencies thoroughly (e.g. OpenZeppelin imports) when new dependencies are added or version of dependencies changes.
- If OpenZeppelin ACL roles are use review & enumerate all of them.
- Check OpenZeppelin security vulnerabilities and if any apply to current PR considering the version of OpenZeppelin contract used.
External calls
- Contract addresses passed in are validated
- 🟢 No user controlled addresses
- No unsafe external calls
- Reentrancy safe
- 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
Deploy
- Deployer permissions are removed after deploy
Thinking
Logic
Good, see notes under flavor.
Deployment Considerations
Are there things that must be done on deploy, or in the wider ecosystem for this code to work. Are they done?
Internal State
We want all shares to be redeemable. The worst case for this is a single withdraw of all shares, which is represented by totalAssets()
. Therefor totalAssets()
should return a number that is less than the actual assets held by the contract. This has been the case in all my testing. Rounding errors on deposits and withdraws add to the solvency, it is not given to users, and rebasing also rebases the solvency dust.
Attack
These wrapped token contracts usually contain a large amount of funds - up to half the tokens for a given Origin product may be in the wrapped contract. If an attacker found a way to drain these funds, they would be very happy.
🟢 The easiest way to drain the assets would be to have a time when convert shares and convert assets cross each other. Because convertAssets()
and convertShares()
are computed from the same numbers, just with different rounding directions, they should never cross.
🟢 Alternately, you can find a way to get convert shares and convert assets to cross each other over time. Obviously they cross each other in a way as yield goes up, but that’s just earning yield, and does not negatively affect other depositors. In theory the rounding error in deposit changes from equal to unfavorable. However this should not let an attacker beat the ratio.
😜 Once rounding errors became a thing, an attacker could front run deposits with tiny deposits of their own, maximizing depositor rounding error. The attacker wouldn’t even profit if holding OETH, since all rounding error is by design stored by the contract and not shared with users.
🟢 If the exchange rate could be manipulated rapidly either up or down, it could cause problems for AMMs or lending platforms using this. In theory, the exchange rate cannot be manipulated down because OE's exchange rate cannot go down. Upwards should be controlled by the vault, and rate limited.
🟢 This code is sensitive to the exact nuances of how OUSD rebases and may have tiny insolvency issues if there is some mismatch in behavior. I’ve fork tested that with ludicrous totalSupply growth, the contract gets more solvent, not less.
🟢 Trappped dust will continue to stay in the contract and grow. Should be fine though.
🟢 There is an instant update of the exchange rate when the exchange rate of OUSD changes. However, this is actually a more restrictive situation than it used to be - before the exchange rate would instantly update on OUSD exchange rate changes as well as on donations or changes in totalAssets()/totalSupply(). At a minimum we are no worse than before.
Flavor
Probably not worth making the below changes after so much security review has happened, but I think it would be more beautiful to:
- use
convertToAssets()
intotalAssets()
, rather than duping the calculation - and change
rebasingCreditsPerTokenHighres()
to_rebasingCreditsPerTokenHighres()
RequirementsThe PR fixes a problem with our Wrapped OETH contract that was vulnerable to donation attacks. It also uses a unique way of using OETH's Easy ChecksAuthentication
Ethereum
Cryptographic code
Gas problems
Black magic
Overflow
Proxy
Events
Medium ChecksRounding and casts
Dependencies
External calls
Tests
Deploy
Strategy SpecificRemove this section if the code being reviewed is not a strategy. Downstream
ThinkingLogicLogic is really trimmed down to the bare necessities. Not using supply and totalAssets when converting between shares and assets makes logic very clean and robust.
Deployment ConsiderationsThe contract handles fresh WOETH deployment and existing one correctly by setting the right adjuster. Internal StateThere isn't much internal state to this contract. All it tracks is its totalSupply but importantly doesn't use it when doing calculation between assets and shares. There is an AttackA user somehow manipulating the conversion between shares and assets in such a way that it would cause rounding issues. Analysis of all possible paths hasn't revealed such possibility FlavorCode is very clean and probably can not reach further simplification. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Requirements
Fix the logic to prevent donation attacks on wOETH contract.
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:
- A way to remove items
- Can be upgraded to get unstuck
- 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.
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
Proxy
- No storage variable initialized at definition when contract used as a proxy implementation.
Events
- All state changing functions emit events
Medium Checks
Rounding and casts
- Contract rounds in the protocols favor
- 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
- Safecast is aways used when casting
Dependencies
- Review any new contract dependencies thoroughly (e.g. OpenZeppelin imports) when new dependencies are added or version of dependencies changes.
- 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
- 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.
Deploy
- Deployer permissions are removed after deploy
Downstream
- We have monitoring on all backend protocol's governances
- We have monitoring on a pauses in all downstream systems
Thinking
Logic
- Correct usage of global & local variables
Deployment Considerations
N/A
Internal State
adjuster
is the only new variable added to the contract. Ideally it could have been a immutable set in the constructor. However, it relies on OETH's state and a few local functions (totalSupply
), which might not be available in the implementation contract.
Attack
Couldn't think of anything. Simulated a few edge case scenarios and they all worked as expected.
Flavor
The code is simple and easy to understand.
🔴 The code inherits from another older PR, but almost all of the changes from that PR are removed in this branch. Would be great if we can point this to master
instead of the older PR. The code is not synced with master
for over a month, it may lead to merge conflicts and unnecessary behaviour of contracts/tests as it is.
🟡 The code only updates the wOETH contract. Doesn't change wOUSD and wOETHb tokens.
The base branch was changed.
…nation_prevention_simplified
057eb46
to
6504385
Compare
thanks @shahthepro all issues addressed:
|
We are planning to prevent big yield jumps by modifying code directly in VaultCore in this PR: #2452
This allows for simplification in WOETH to completely rely on rebasingCreditsPerToken to deduce its own exchange rate.
Code Change Checklist
To be completed before internal review begins:
Internal review: