HydraDX - J4X's results

HydraDX Omnipool - An Ocean of Liquidity for Polkadot Trade an abundance of assets in a single pool. The HydraDX Omnipool is efficient, sustainable and trustless.

General Information

Platform: Code4rena

Start Date: 02/02/2024

Pot Size: $100,000 USDC

Total HM: 11

Participants: 27

Period: 28 days

Judge: Lambda

Total Solo HM: 4

Id: 327

League:

HydraDX

Findings Distribution

Researcher Performance

Rank: 1/27

Findings: 8

Award: $23,597.19

🌟 Selected for report: 4

🚀 Solo Findings: 2

Findings Information

🌟 Selected for report: J4X

Labels

bug
2 (Med Risk)
grade-a
primary issue
selected for report
sponsor disputed
sufficient quality report
:robot:_72_group
M-01

Awards

7592.9411 USDC - $7,592.94

External Links

Lines of code

https://github.com/code-423n4/2024-02-hydradx/blob/main/HydraDX-node/runtime/hydradx/src/system.rs#L65-L87

Vulnerability details

Bug Description

The EMA oracle, designed to utilize HydraDX's Omnipools and StableSwap for exchange rate information, operates by monitoring activities within these liquidity pools. It looks for specific operations like exchanges, deposits, and withdrawals to adjust the assets' exchange rates accordingly. This updating process is not continuous but occurs when the responsible hooks are called by the StableSwap/Omnipool

The system, although thorough, does not account for price update triggers in the event of direct asset transfers to Stableswap, as these do not set off any hooks within the oracle. This lapse means that such direct transfers can alter asset prices within the liquidity pools without the oracle's knowledge, potentially leading to misleading exchange rates.

Moreover, there's a risk of manipulation by bad actors who might use direct transfers to StableSwap in an effort to sway the arbitrage process, especially during periods of network congestion. Such interference could unjustly prevent necessary liquidations within lending protocols.

Impact

The issue allows a malicious user to change the price of the AMM without updating the oracle.

Proof of Concept

The issue can be validated when looking at the runtime configuration. The configuration only restricts transfers to the Omnipool address, but not to the StableSwap address.

// filter transfers of LRNA and omnipool assets to the omnipool account
if let RuntimeCall::Tokens(orml_tokens::Call::transfer { dest, currency_id, .. })
| RuntimeCall::Tokens(orml_tokens::Call::transfer_keep_alive { dest, currency_id, .. })
| RuntimeCall::Tokens(orml_tokens::Call::transfer_all { dest, currency_id, .. })
| RuntimeCall::Currencies(pallet_currencies::Call::transfer { dest, currency_id, .. }) = call
{
	// Lookup::lookup() is not necessary thanks to IdentityLookup
	if dest == &Omnipool::protocol_account() && (*currency_id == hub_asset_id || Omnipool::exists(*currency_id))
	{
		return false;
	}
}
// filter transfers of HDX to the omnipool account
if let RuntimeCall::Balances(pallet_balances::Call::transfer { dest, .. })
| RuntimeCall::Balances(pallet_balances::Call::transfer_keep_alive { dest, .. })
| RuntimeCall::Balances(pallet_balances::Call::transfer_all { dest, .. })
| RuntimeCall::Currencies(pallet_currencies::Call::transfer_native_currency { dest, .. }) = call
{
	// Lookup::lookup() is not necessary thanks to IdentityLookup
	if dest == &Omnipool::protocol_account() {
		return false;
	}
}

Tools Used

Manual Review

Recommended Mitigation Steps

The issue can be mitigated by disabling transfers to the StableSwap pools, similar to how it is implemented for the Omnipool.

Assessed type

Oracle

#0 - c4-pre-sort

2024-03-03T09:29:25Z

0xRobocop marked the issue as sufficient quality report

#1 - c4-pre-sort

2024-03-03T09:29:27Z

0xRobocop marked the issue as primary issue

#2 - c4-sponsor

2024-03-06T14:17:53Z

enthusiastmartin (sponsor) disputed

#3 - enthusiastmartin

2024-03-06T14:20:03Z

We believe this is not an issue, impact is not obvious. Oracle is not guaranteed to be always correct.

#4 - OpenCoreCH

2024-03-08T10:07:54Z

The finding itself is valid, but only speculates about potential impacts ("potentially leading to misleading exchange rates."). Because of that, it is a design recommendation -> QA.

#5 - c4-judge

2024-03-08T10:08:02Z

OpenCoreCH changed the severity to QA (Quality Assurance)

#6 - c4-judge

2024-03-09T11:00:16Z

OpenCoreCH marked the issue as grade-a

#7 - J4X-98

2024-03-13T02:00:46Z

Hi @OpenCoreCH ,

thank you very much for reviewing this contest. I am sorry that my issue was a bit inconclusive about the severity/results of this. The same impact (but for omnipool) has already been found in one of the earlier audits at Finding A1, this is why I kept my issue intentionally rather short, which was suboptimal in hindsight.

The oracle should always return the current price of the assets at stableswap/omnipool. As identified in the other audit this could be broken for the Omnipool by transferring assets directly to the Omnipool, making the price outdated. This was confirmed as a medium severity finding by the sponsor and fixed by implementing guards that blocked any transfers of tokens directly to the Omnipool. Unfortunately, the team has forgotten to implement the same safety measures when the StableSwap AMM was added to the protocol. As a result of this, the attack path is once again possible for all assets listed on StableSwap.

While I have not described an attack path that leads to an attacker profiting from this (which might be possible), the "attack" path of donating to make the oracle outdated, that I have described shows a way how the oracle becomes outdated which should never be the case. To keep it simple, this leads to one of the components of the protocol not functioning as intended (the oracle returning a wrong price) leading to the damage scenario of "Assets not at direct risk, but the function of the protocol impacted".

Additionally i would like to add that finding #73 leads to the exact same damage scenario, the oracle returning an incorrect price for an asset, and has been confirmed as medium severity.

#8 - OpenCoreCH

2024-03-15T09:48:00Z

Keeping at QA because of missing impact / attack path. This might lead to problems in the protocol and be a valid medium or high then, but the issue does not demonstrate that.

#73 mentions a potential impact (third-party protocols) that has external requirements, but is still valid nevertheless.

#9 - J4X-98

2024-03-15T09:57:29Z

Hi @OpenCoreCH,

thanks for your review but I must tell you that I disagree with you differentiating between this issue and #73. They both result in the exact same state of the oracle returning an incorrect price for an asset.

Additionally, you mention that #73 offers an impact while this one does not. The impact that #73 describes is "So any protocol that uses this oracle as a price source would receive an incorrect price for the re-added asset for a short period of time." which can be shortened down to "external protocols relying on this oracle might break". My issue describes " Such interference could unjustly prevent necessary liquidations within lending protocols." which can also be shortened to "external protocols relying on this oracle might break too". It is just more focussed on lending protocols as this is the first thing that came to mind for me.

I'd kindly ask you to reconsider this and let me know your final judgment.

#10 - OpenCoreCH

2024-03-15T15:13:39Z

That's a good point, I previously missed the mention of integration with other protocols. Because a potential realistic impact with external requirements is mentioned and the finding itself is valid, I am upgrading it to medium.

#11 - c4-judge

2024-03-15T15:13:50Z

This previously downgraded issue has been upgraded by OpenCoreCH

#12 - c4-judge

2024-03-15T15:13:59Z

OpenCoreCH marked the issue as selected for report

Findings Information

🌟 Selected for report: J4X

Also found by: 3docSec, carrotsmuggler

Labels

bug
2 (Med Risk)
disagree with severity
high quality report
primary issue
selected for report
sponsor confirmed
:robot:_17_group
M-02

Awards

2050.0941 USDC - $2,050.09

External Links

Lines of code

https://github.com/code-423n4/2024-02-hydradx/blob/main/HydraDX-node/pallets/stableswap/src/lib.rs#L638

Vulnerability details

Bug Description

The StableSwap AMM of the HydraDx protocol implements safeguards against low liquidity so that too high price fluctuations are prevented, and manipulating the price becomes harder. These safeguards are enforced based on the MinPoolLiquidity which is a constant that describes the minimum liquidity that should be in a pool. Additionally, a pool is allowed to have a liquidity of 0, which would occur in the case of the creation of the pool, or by users withdrawing all their liquidity. This could also be defined as an invariant.

totalPoolIssuance(poolId)>=MinPoolLiquidity∣∣totalPoolIssuance(poolId)==0totalPoolIssuance(poolId) >= MinPoolLiquidity || totalPoolIssuance(poolId) == 0

When a user wants to withdraw his liquidity, he can use either the remove_liquidity_one_asset() function or the withdraw_asset_amount() function.

remove_liquidity_one_asset()

To ensure holding the invariant 2 checks are implemented in the remove_liquidity_one_asset() function.

The first check checks if the user either leaves more than MinPoolLiquidity shares in the pool or withdraws all his shares.

let current_share_balance = T::Currency::free_balance(pool_id, &who);

ensure!(
	current_share_balance == share_amount
		|| current_share_balance.saturating_sub(share_amount) >= T::MinPoolLiquidity::get(),
	Error::<T>::InsufficientShareBalance
);

The second check checks if the total liquidity in the pool would fall below the intended amount of shares.

let share_issuance = T::Currency::total_issuance(pool_id);

ensure!(
	share_issuance == share_amount
		|| share_issuance.saturating_sub(share_amount) >= T::MinPoolLiquidity::get(),
	Error::<T>::InsufficientLiquidityRemaining
);

These 2 checks work perfectly at holding the invariant at all times.

withdraw_asset_amount()

Unfortunately the second function for withdrawing liquidity withdraw_asset_amount() omits one of the checks. The function only checks if the user either withdraws all his shares or leaves more than the MinPoolLiquidity shares.

let current_share_balance = T::Currency::free_balance(pool_id, &who);

ensure!(
	current_share_balance == shares
		|| current_share_balance.saturating_sub(shares) >= T::MinPoolLiquidity::get(),
	Error::<T>::InsufficientShareBalance
);

One might state now that this could never break the invariant, as if every user's shares are either more than MinPoolLiquidity or zero, the total liquidity can never fall below MinPoolLiquidity without being 0. Unfortunately, this approach forgets that users can transfer their shares to other addresses. This allows a user to transfer an amount as low as 1 share to another address, and then withdraw all his shares. As the check would only ensure that he is withdrawing all his shares it would pass. If he was the only liquidity provider, there now would only be 1 share of liquidity left in the pool breaking the invariant of $totalPoolIssuance(poolId) >= MinPoolLiquidity$.

Impact

The issue allows a user to break the invariant about the MinPoolLiquidity and either push the pool into a state where it can easily be manipulated, or prevent other users from withdrawing their shares.

Proof of Concept

There are 2 ways how this could be exploited:

1. Breaking the invariant and letting the pool Liquidity fall below MinPoolLiquidity

A malicious LP could abuse this functionality to push the pool into a state where its total liquidity is below MinPoolLiquidity, and could be as low as 1 share, allowing for easy price manipulation. To achieve this the attacker would do the following:

  1. User deposits MinPoolLiquidty using one of the functions
  2. User transfers 1 share to another address controlled by him (so he does not lose any value)
  3. User withdraws all his shares using withdraw_asset_amount()
  4. The function will pass as it does not check the whole pool liquidity
  5. The pool now only has 1 share of liquidity inside and can easily be manipulated

2. DOSing withdrawing through remove_liquidity_one_asset for others

Let's consider a case where there are only 2 liquidity providers and one of them is malicious and wants to prevent the other from withdrawing through remove_liquidity_one_asset(). This could for example be the case if the other is a smart contract, that can only withdraw through this function.

  1. Both deposit MinPoolLiquidty
  2. Malicious user transfers 1 share to another address controlled by him (so he does not lose any value)
  3. Malicious user withdraws all his liquidity using withdraw_asset_amount()
  4. Normal user now tries to withdraw all of his liquidity using remove_liquidity_one_asset()
  5. The call fails as the total pool liquidity (which is checked in this one) would fall below MinPoolLiquidty
  6. The user is forced to keep his liquidity in the pool until someone else adds liquidity.

Tools Used

Manual Review

Recommended Mitigation Steps

The issue can be mitigated by also adding a check for the total pool liquidity to withdraw_asset_amount()

let share_issuance = T::Currency::total_issuance(pool_id);

ensure!(
	share_issuance == share_amount
		|| share_issuance.saturating_sub(share_amount) >= T::MinPoolLiquidity::get(),
	Error::<T>::InsufficientLiquidityRemaining
);

Assessed type

Other

#0 - c4-pre-sort

2024-03-02T02:09:40Z

0xRobocop marked the issue as primary issue

#1 - c4-pre-sort

2024-03-02T02:09:43Z

0xRobocop marked the issue as sufficient quality report

#2 - c4-pre-sort

2024-03-03T19:01:10Z

0xRobocop marked the issue as high quality report

#3 - c4-sponsor

2024-03-06T13:56:55Z

enthusiastmartin (sponsor) confirmed

#4 - c4-sponsor

2024-03-06T13:56:58Z

enthusiastmartin marked the issue as disagree with severity

#5 - enthusiastmartin

2024-03-07T08:38:00Z

Although the check is missing,the issue is not high risk. Any limit that we have in our AMM are soft limits, meaning it is designed to protect mainly users, they dont have to be always respected.

there is no evidence, that the state of pool would be exploitable.

#6 - OpenCoreCH

2024-03-07T21:43:17Z

The warden identified how a security limit can be circumvented in some rare edge cases and how this could lead to a temporary DoS, Medium is appropriate here.

#7 - c4-judge

2024-03-07T21:43:21Z

OpenCoreCH marked the issue as selected for report

#8 - Frankcastleauditor

2024-03-13T23:33:09Z

Hi @OpenCoreCH I believe the severity of this issue should be reconsidered due to the impact it has:

  1. This issue will not lead to a DoS or a lock of funds, as the liquidity provider can withdraw all their liquidity by calling the function withdraw_asset_amount() instead of remove_liquidity_one_asset, which encounters an issue with the limit of minimum liquidity. Thus, the user can simply withdraw all their liquidity in the same manner the attacker has, since the function withdraw_asset_amount() does not check for a minimum limit of shares remaining in the pool. Therefore, there is no risk of funds being locked or DoS for the liquidity providers. the report mentioned that :
  1. A malicious user withdraws all their liquidity using withdraw_asset_amount().
  2. A normal user then tries to withdraw all of their liquidity using remove_liquidity_one_asset().

Here, the normal user can use the function withdraw_asset_amount() instead of remove_liquidity_one_asset(), and the entire liquidity removal will be completed.

Therefore, the only impact of this issue is allowing dust accounts to exist in the pool without any other impact, which should not be considered a medium severity issue.

#9 - J4X-98

2024-03-14T02:25:40Z

Hey @Frankcastleauditor,

you are correct that the user could use remove_liquidity_one_asset() to withdraw his shares, but this would require him to abuse the same issue as the malicious user.

1. DOS

Regarding the DOS, this issue still leads to a DOS on one of the functions of the protocol, which suffices medium, as per the severity guidelines Med requires "Assets not at direct risk, but the function of the protocol or its availability could be impacted". In this case the function of remove_liquidity_one_asset() is clearly impacted and not usable. I mentioned in my issue that the user could be a contract, which is programmed to interact through the remove_liquidity_one_asset() function. As a lot of the interactions with an AMM are actually contracts and not EOA, this is a very usual case. The contract can't be changed later on so it would never be able to withdraw its shares again, although being 100% correctly programmed.

2. Broken Invariant

From the code, one can see that the intended invariant for the liquidity in a pool is sharesInPool == 0 || sharesInPool >= MinPoolLiquidty. This is done so that pools with very low liquidity can't exist as they can easily be manipulated, which a lot of other AMMs do too. The sponsor described this as "to protect mainly the users" in the comment above. This issue shows in "1. Breaking the invariant and letting the pool Liquidity fall below MinPoolLiquidity" how this invariant can be broken so that the pool is easily manipulatable. How this should be graded can be seen in the Supreme Court decisions:

High - A core invariant of the protocol can be broken for an extended duration.

Medium - A non-core invariant of the protocol can be broken for an extended duration or at scale, or an otherwise high-severity issue is reduced due to hypotheticals or external factors affecting likelihood.

3. Conclusion

So in total, the issue leads to 2 impacts, which both would be (at least) of medium severity:

  1. DOS of the remove_liquidity_one_asset() function.
  2. Breaking of the Pool liquidity invariant

Findings Information

Awards

152.7401 USDC - $152.74

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
edited-by-warden
:robot:_15_group
duplicate-93

External Links

Lines of code

https://github.com/code-423n4/2024-02-hydradx/blob/main/HydraDX-node/pallets/omnipool/src/lib.rs#L577 https://github.com/code-423n4/2024-02-hydradx/blob/main/HydraDX-node/pallets/omnipool/src/lib.rs#L716

Vulnerability details

Bug Description

The HydraDx Protocol facilitates user interactions with its AMM, the Omnipool, by offering liquidity addition and withdrawal functions. A key security feature aimed at preventing front-running attacks on remove_liquidity() transactions is the implementation of the T::PriceBarrier::ensure_price() function.

This function compares the current price, potentially altered by front-running, with the average price provided by the oracle, allowing a maximum deviation of 1% as defined in the runtime configuration.

fn ensure_price(who: &AccountId, asset_a: AssetId, asset_b: AssetId, current_price: EmaPrice) -> Result<(), ()> {
	if WhitelistedAccounts::contains(who) {
		return Ok(());
	}

	let max_allowed = FixedU128::from(MaxAllowed::get());

	let oracle_price = ExternalOracle::get_price(asset_a, asset_b).map_err(|_| ())?;
	let external_price = FixedU128::checked_from_rational(oracle_price.n, oracle_price.d).ok_or(())?;
	let current_spot_price = FixedU128::checked_from_rational(current_price.n, current_price.d).ok_or(())?;

	let max_allowed_difference = max_allowed
		.checked_mul(&current_spot_price.checked_add(&external_price).ok_or(())?)
		.ok_or(())?;

	let diff = if current_spot_price >= external_price {
		current_spot_price.saturating_sub(external_price)
	} else {
		external_price.saturating_sub(current_spot_price)
	};

	ensure!(
		diff.checked_mul(&FixedU128::from(2)).ok_or(())? <= max_allowed_difference,
		()
	);
	Ok(())
}

However, this mechanism only guards against front-run-induced losses exceeding 1%, leaving users vulnerable to smaller, yet potentially frequent, front-running attacks.

Impact

Malicious actors can exploit this vulnerability to front-run remove_liquidity() and transactions, imposing losses on legitimate users within the 1% deviation threshold. This risk exposes users to potential financial losses from transactions that the protocol does not adequately protect.

Proof of Concept

A proof of concept demonstrates a scenario where a call to remove_liquidity() is front-run, causing a minor price deviation. Due to the deviation being within the 1% threshold, the protocol's current implementation does not prevent the transaction, illustrating the vulnerability to such front-running attacks.

#[test]
fn remove_liquidity_should_pass_when_frontrun_below_1() {
	ExtBuilder::default()
		.with_endowed_accounts(vec![
			(Omnipool::protocol_account(), DAI, 1000 * ONE),
			(Omnipool::protocol_account(), HDX, NATIVE_AMOUNT),
			(LP2, 1_000, 2000 * ONE),
			(LP1, 1_000, 5000 * ONE),
		])
		.with_initial_pool(FixedU128::from_float(0.5), FixedU128::from(1))
		.with_token(1_000, FixedU128::from_float(0.65), LP2, 2000 * ONE)
		.with_max_allowed_price_difference(Permill::from_percent(1))
		.build()
		.execute_with(|| {
			let current_position_id = <NextPositionId<Test>>::get();
			assert_ok!(Omnipool::add_liquidity(RuntimeOrigin::signed(LP1), 1_000, 400 * ONE));

			//Minor adjustment of 99/100 
			EXT_PRICE_ADJUSTMENT.with(|v| {
				*v.borrow_mut() = (99, 100, true);
			});

			assert_noop!(
				Omnipool::remove_liquidity(RuntimeOrigin::signed(LP1), current_position_id, 200 * ONE,),
				Error::<Test>::PriceDifferenceTooHigh
			);
		});
}

To run the test it needs to be added to the omnipool/src/tests/remove_liquidity.rs file.

Tools Used

Manual Review

Recommended Mitigation Steps

This issue can be fixed by allowing the user to define a slippage parameter, similar to the one implemented in StableSwaps functions for withdrawing liquidity. This way a user does not have to endure a loss of up to 1%.

Assessed type

MEV

#0 - c4-pre-sort

2024-03-03T06:38:28Z

0xRobocop marked the issue as duplicate of #158

#1 - c4-judge

2024-03-07T21:12:50Z

OpenCoreCH marked the issue as satisfactory

#2 - c4-judge

2024-03-09T11:17:48Z

OpenCoreCH changed the severity to 2 (Med Risk)

Findings Information

Awards

152.7401 USDC - $152.74

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
edited-by-warden
:robot:_15_group
duplicate-93

External Links

Lines of code

https://github.com/code-423n4/2024-02-hydradx/blob/main/HydraDX-node/pallets/stableswap/src/lib.rs#L475

Vulnerability details

Bug Description

The stableswap AMM in the HydraDX protocol allows users to deposit liquidity to facilitate the market maker functionality. It is a well known issue that calls to add/withdraw liquidty on vaults/amm pools can be frontrun to reduce the users received shares. In the add_liquidity_shares() this is already accounted for as the user provides the amount of shares he wants to receive, and can control his slippage by setting the max_asset_amount variable.

Unfortunately, in the add_liquidity() function, where the user sets how many assets he wants to provide, he is not able to set the minimum amount of assets that he wants to receive. This way the user could have to endure up to 100% slippage, resulting in massive losses.

Impact

This issue allows a malicious attacker to frontrun calls to add_Liquidity() so that users incur high slippage losses. The users can't protect themselves against this as they are not able to set a slippage parameter in the function.

Proof of Concept

The issue can already be spotted when looking at add_liquidty()'s function signature.

#[pallet::call_index(3)]
#[pallet::weight(<T as Config>::WeightInfo::add_liquidity()
					.saturating_add(T::Hooks::on_liquidity_changed_weight(MAX_ASSETS_IN_POOL as usize)))]
#[transactional]
pub fn add_liquidity(
	origin: OriginFor<T>,
	pool_id: T::AssetId,
	assets: Vec<AssetAmount<T::AssetId>>,
) -> DispatchResult {
	let who = ensure_signed(origin)?;

	let shares = Self::do_add_liquidity(&who, pool_id, &assets)?;

	Self::deposit_event(Event::LiquidityAdded {
		pool_id,
		who,
		shares,
		assets,
	});

	Ok(())
}

An exemplary situation can be described as follows

  1. Alice wants to add liquidity and calls the add_liquidity() function
  2. Bob spots this call and front runs it
  3. He adds a lot of liquidity/trades so that Alice gets less shares than expected
  4. Alice can't set slippage so her transaction will still execute

Tools Used

Manual Review

Recommended Mitigation Steps

The issue can be mitigated by allowing the user to pass a minShares parameter. At the end of the function, it should check if the user receives more shares than this parameter, and otherwise revert.

ensure!(minShares <= shares, Error::<T>::SlippageLimit);

Assessed type

MEV

#0 - c4-pre-sort

2024-03-03T08:55:50Z

0xRobocop marked the issue as duplicate of #158

#1 - c4-judge

2024-03-07T21:13:04Z

OpenCoreCH marked the issue as satisfactory

#2 - c4-judge

2024-03-09T11:17:48Z

OpenCoreCH changed the severity to 2 (Med Risk)

Findings Information

Awards

152.7401 USDC - $152.74

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
:robot:_15_group
duplicate-93

External Links

Lines of code

https://github.com/code-423n4/2024-02-hydradx/blob/main/HydraDX-node/pallets/omnipool/src/lib.rs#L577

Vulnerability details

Bug Description

HydraDx Protocol's Automated Market Maker (AMM), known as the Omnipool, enables user transactions like adding liquidity. To combat potential front-running of add_liquidity() calls, the protocol uses the T::PriceBarrier::ensure_price() mechanism. This function checks if the current price, which may have been manipulated by front-running, deviates from the oracle's average price by no more than 1%, a limit set in the runtime configuration.

fn ensure_price(who: &AccountId, asset_a: AssetId, asset_b: AssetId, current_price: EmaPrice) -> Result<(), ()> {
	if WhitelistedAccounts::contains(who) {
		return Ok(());
	}

	let max_allowed = FixedU128::from(MaxAllowed::get());

	let oracle_price = ExternalOracle::get_price(asset_a, asset_b).map_err(|_| ())?;
	let external_price = FixedU128::checked_from_rational(oracle_price.n, oracle_price.d).ok_or(())?;
	let current_spot_price = FixedU128::checked_from_rational(current_price.n, current_price.d).ok_or(())?;

	let max_allowed_difference = max_allowed
		.checked_mul(&current_spot_price.checked_add(&external_price).ok_or(())?)
		.ok_or(())?;

	let diff = if current_spot_price >= external_price {
		current_spot_price.saturating_sub(external_price)
	} else {
		external_price.saturating_sub(current_spot_price)
	};

	ensure!(
		diff.checked_mul(&FixedU128::from(2)).ok_or(())? <= max_allowed_difference,
		()
	);
	Ok(())
}

However, this safeguard only protects against price manipulations that exceed a 1% deviation, leaving a gap for smaller front-running attacks to occur without detection.

Impact

This vulnerability allows adversaries to execute front-running attacks on add_liquidity() transactions that result in less than a 1% loss for the targeted users, thereby exposing them to repeated minor financial losses that are not mitigated by the current protocol defenses.

Proof of Concept

The proof of concept demonstrates a scenario where a transaction to add_liquidity() is successfully front-run, leading to a slight price change within the 1% tolerance. The protocol fails to prevent the completion of the transaction, showcasing its susceptibility to such front-running strategies.

#[test]
fn add_liquidity_should_pass_when_frontrun_below() {
	ExtBuilder::default()
		.with_endowed_accounts(vec![
			(Omnipool::protocol_account(), DAI, 1000 * ONE),
			(Omnipool::protocol_account(), HDX, NATIVE_AMOUNT),
			(LP2, 1_000, 2000 * ONE),
			(LP1, 1_000, 5000 * ONE),
		])
		.with_initial_pool(FixedU128::from_float(0.5), FixedU128::from(1))
		.with_token(1_000, FixedU128::from_float(0.65), LP2, 2000 * ONE)
		.with_max_allowed_price_difference(Permill::from_percent(1))
		.build()
		.execute_with(|| {
			let current_position_id = <NextPositionId<Test>>::get();
			assert_ok!(Omnipool::add_liquidity(RuntimeOrigin::signed(LP1), 1_000, 400 * ONE));

			//Minor adjustment of 99/100 
			EXT_PRICE_ADJUSTMENT.with(|v| {
				*v.borrow_mut() = (99, 100, true);
			});

			assert_noop!(
				Omnipool::add_liquidity(RuntimeOrigin::signed(LP1), current_position_id, 200 * ONE,),
				Error::<Test>::PriceDifferenceTooHigh
			);
		});
}

To run the test it needs to be added to the omnipool/src/tests/remove_liquidity.rs file.

Tools Used

Manual Review

Recommended Mitigation Steps

A practical solution involves allowing users to set their own slippage tolerance level. By adopting a customizable slippage parameter, similar to the approach used in StableSwap's liquidity withdrawal functions, users can better control their acceptable loss margins, potentially avoiding the default up to 1% loss currently in place.

Assessed type

MEV

#0 - c4-pre-sort

2024-03-03T06:37:56Z

0xRobocop marked the issue as duplicate of #158

#1 - c4-judge

2024-03-07T21:12:10Z

OpenCoreCH marked the issue as satisfactory

#2 - c4-judge

2024-03-09T11:17:48Z

OpenCoreCH changed the severity to 2 (Med Risk)

#3 - J4X-98

2024-03-13T02:05:05Z

Hi @OpenCoreCH,

thank you very much for reviewing this contest. I would like to add that the primary issue of this only describes a part of the actual problem. In the protocol, there are 3 functions that are missing slippage control.

  • StableSwap.add_liquidity() (#15)
  • Omnipool.add_liquidity() () (#55)
  • Omnipoool.remove_liquidity() (#40)

The current primary issue only describes the missing slippage parameters for Omnipoool.remove_liquidity() but not the other 2 problems. In the past, the C4 judging on similar situations was to split multiple slippage problems in one protocol into separate issues. Examples of this are:

Vader Protocol:

BadgerDAO

Asymmetry Finance:

In the C4 judging documentation duplicates are defined as follows:

  • The findings are duplicates if they share the same root cause.
  • More specifically, if fixing the Root Cause (in a reasonable manner) would cause the finding to no longer be exploitable, then the findings are duplicates

Neither of those is true for the 3 issues described above. They all result out of different parts of the code, and adding slippage protection to one of the functions will not fix the other issues.

Additionally, I want to add that #177 is actually a duplicate of #84 and not of #93

#4 - OpenCoreCH

2024-03-15T09:21:06Z

I would argue that they conceptually have the same root cause (missing slippage check) and are resolved by the same solution. Of course, this would affect different lines of code / functions, but if we make this the single criteria for duplicates, it would imply that a codebase with 20 mint functions could have 20 different slippage issues, which seems not ideal.

#5 - J4X-98

2024-03-15T09:27:55Z

Hey @OpenCoreCH,

I understand and agree with you. Nevertheless I would kindly ask you to reconsider why a warden/issue that only found 1/3 problems in the codebase is chosen as the primary issue and also why wardens that only found 1 or 2 out of the 3 issues are awarded fully and not partially.

I think it makes more sense to make me (or another warden) that found all of the issues as the prime issue and also only partially award wardens that did not find all issues.

#6 - OpenCoreCH

2024-03-15T09:50:55Z

At first, another issue (#158) that mentions all three functions was chosen as the primary one. While the current only mentions one function, I still think that it provides the most value because it analyzes the issue in detail with a numerical example and directly highlights the potential impact.

#7 - carrotsmuggler

2024-03-15T10:05:08Z

Hi @J4X-98 .Since you are suggesting rewarding a warden who found all the issues as the primary report, i would like to point out I did find all the issues: #93, #85, #92. They are all duped with #93 now.

#8 - J4X-98

2024-03-15T10:08:38Z

Hey @carrotsmuggler,

did not see that. Congrats on also finding all issues. Consider my comment above void.

Findings Information

🌟 Selected for report: carrotsmuggler

Also found by: J4X, QiuhaoLi

Labels

bug
2 (Med Risk)
satisfactory
:robot:_42_group
duplicate-86

Awards

1576.9955 USDC - $1,577.00

External Links

Lines of code

https://github.com/code-423n4/2024-02-hydradx/blob/main/HydraDX-node/math/src/stableswap/math.rs#L288

Vulnerability details

Bug Description

Whenever a new liquidity pool on StableSwap gets generated, a user has to deposit the initial liquidity for it. This is done by the user calling addLiquidity() and providing the initial liquidity for all assets.

Unfortunately due to an underflow in the calculation of the shares, a user trying to withdraw all of his initial liquidity again will never be able to. This is due to the calculation of the reserves in calculate_withdraw_one_asset() underflowing if the reserve of one asset is 0.

reserve.checked_mul(d1)?.checked_div(d_hp)?.checked_sub(y_hp)?

Impact

Due to this issue, the first caller of add_liquidity() can never fully withdraw it again.

Proof of Concept

The following POC showcases the issue

#[test]
fn withdraw_initial_liquidity() {
	let asset_a: AssetId = 1;
	let asset_b: AssetId = 2;

	ExtBuilder::default()
		.with_endowed_accounts(vec![
			(BOB, 1, 200 * ONE),
			(BOB, 2, 200 * ONE),
			(ALICE, 1, 200 * ONE),
			(ALICE, 2, 200 * ONE),
		])
		.with_registered_asset("one".as_bytes().to_vec(), asset_a, 12)
		.with_registered_asset("two".as_bytes().to_vec(), asset_b, 12)
		.with_pool(
			ALICE,
			PoolInfo::<AssetId, u64> {
				assets: vec![asset_a, asset_b].try_into().unwrap(),
				initial_amplification: NonZeroU16::new(1000).unwrap(),
				final_amplification: NonZeroU16::new(1000).unwrap(),
				initial_block: 0,
				final_block: 0,
				fee: Permill::from_percent(0),
			},
			InitialLiquidity {
				account: ALICE,
				assets: vec![
					AssetAmount::new(asset_a, 100 * ONE),
					AssetAmount::new(asset_b, 100 * ONE),
				],
			},
		)
		.build()
		.execute_with(|| {
			let pool_id = get_pool_id_at(0);
			let amount_added = 100 * ONE;

			//Alice wants to withdraw her initial liquidity again
			assert_noop!(Stableswap::withdraw_asset_amount(
				RuntimeOrigin::signed(ALICE),
				pool_id,
				asset_a,
				100 * ONE,
				340282366920938463463374607431768211455,
			),
			Error::<Test>::InvalidInitialLiquidity);

		});
}

The test can be added to the pallets/stableswap/src/test/add_liquidity.rs file.

Tools Used

Manual Review

Recommended Mitigation Steps

The issue can be mitigated by requiring the creator of the pool (admin) to deposit/donate MinPoolLiquidity of each token into the pool and burn the shares when calling create_pool. This way the first user calling addLiquidity() can withdraw all his liquidity again.

Assessed type

Under/Overflow

#0 - c4-pre-sort

2024-03-03T04:04:25Z

0xRobocop marked the issue as primary issue

#1 - c4-pre-sort

2024-03-03T04:04:28Z

0xRobocop marked the issue as sufficient quality report

#2 - c4-pre-sort

2024-03-03T04:27:39Z

0xRobocop marked the issue as remove high or low quality report

#3 - 0xRobocop

2024-03-03T04:29:35Z

The PoC seems a bit off, it makes a call to withdraw_asset_amount, but this function never calls calculate_withdraw_one_asset which, as per the report, is where the root cause of the revert.

#4 - c4-pre-sort

2024-03-03T04:30:04Z

0xRobocop marked the issue as duplicate of #86

#5 - c4-judge

2024-03-08T10:20:04Z

OpenCoreCH marked the issue as satisfactory

#6 - c4-judge

2024-03-08T10:21:15Z

OpenCoreCH marked the issue as selected for report

#7 - c4-judge

2024-03-08T10:21:26Z

OpenCoreCH removed the grade

#8 - c4-judge

2024-03-08T10:22:10Z

OpenCoreCH marked issue #198 as primary and marked this issue as a duplicate of 198

#9 - c4-judge

2024-03-08T10:23:21Z

OpenCoreCH marked the issue as satisfactory

Findings Information

🌟 Selected for report: J4X

Labels

bug
2 (Med Risk)
high quality report
primary issue
selected for report
sponsor disputed
M-08

Awards

7592.9411 USDC - $7,592.94

External Links

Lines of code

https://github.com/code-423n4/2024-02-hydradx/blob/main/HydraDX-node/pallets/omnipool/src/lib.rs#L716

Vulnerability details

Bug Description

When using the substrate framework, it is one of the main goals of developers to prevent storage bloat. If storage can easily be bloated by users, this can lead to high costs for the maintainers of the chain and a potential DOS. A more in detail explanation can be found here.

The Omnipool allows users to deposit liquidity to earn fees on swaps. Whenever a user deposits liquidity through add_liquidity(), he gets an NFT minted and the details of his deposit are stored in the Positions map.

let instance_id = Self::create_and_mint_position_instance(&position_owner)?;

<Positions<T>>::insert(instance_id, lp_position);

To ensure that this storage is only used for serious deposits, it is ensured to be above MinimumPoolLiquidity which is 1,000,000 tokens in the runtime configuration.

ensure!(
	amount >= T::MinimumPoolLiquidity::get() && amount > 0,
	Error::<T>::MissingBalance
);

Additionally, whenever a deposit gets fully withdrawn, the storage entry is removed.

if updated_position.shares == Balance::zero() {
	// All liquidity removed, remove position and burn NFT instance

	<Positions<T>>::remove(position_id);
	T::NFTHandler::burn(&T::NFTCollectionId::get(), &position_id, Some(&who))?;

	Self::deposit_event(Event::PositionDestroyed {
		position_id,
		owner: who.clone(),
	});
}

Unfortunately, this implementation does not take into account that a malicious user can add MinimumPoolLiquidity tokens, and then instantly withdraw all but 1. In that case, he has incurred almost no cost for bloating the storage (besides the 1 token and gas fees) and can keep on doing this countless times.

Impact

The issue allows a malicious attacker to bloat the storage in a cheap way. If done often enough this allows him to DOS the functionality of the HydraDX protocol by bloating the storage significantly until it can't be maintained anymore. If the attacker uses a very low-value token, he only incurs the gas fee for each new entry.

If we consider that the intended cost for adding a new position entry (to potentially DOS) as defined by the MinimumPoolLiquidity should be 1_000_000 tokens, this issue allows an attacker to get the same storage bloat for 1/1_000_000 or 0.0001% of the intended cost.

Proof of Concept

The following testcase showcases the issue.

#[test]
fn remove_liquidity_but_one() {
	ExtBuilder::default()
		.with_endowed_accounts(vec![
			(Omnipool::protocol_account(), DAI, 1000 * ONE),
			(Omnipool::protocol_account(), HDX, NATIVE_AMOUNT),
			(LP2, 1_000, 2000 * ONE),
			(LP1, 1_000, 5000 * ONE),
		])
		.with_initial_pool(FixedU128::from_float(0.5), FixedU128::from(1))
		.with_token(1_000, FixedU128::from_float(0.65), LP2, 2000 * ONE)
		.build()
		.execute_with(|| {

			let liq_added = 1_000_000; //Exactly MinimumPoolLiquidity
			let current_position_id = <NextPositionId<Test>>::get();

			assert_ok!(Omnipool::add_liquidity(RuntimeOrigin::signed(LP1), 1_000, liq_added));

			let liq_removed = 200 * ONE;
			assert_ok!(Omnipool::remove_liquidity(
				RuntimeOrigin::signed(LP1),
				current_position_id,
				1_000_000-1 //Remove all but one asset
			));

			let position = Positions::<Test>::get(current_position_id).unwrap();
			let expected = Position::<Balance, AssetId> {
				asset_id: 1_000,
				amount: 1,
				shares: 1,
				price: (1300000000650000, 2000000001000000),
			};

			//There now is a position with 1 single Wei bloating the storage
			assert_eq!(position, expected);
		});
}

The testcase can be added to the pallets/omnipool/src/tests/remove_liquidity.rs file.

Tools Used

Manual Review

Recommended Mitigation Steps

The issue can be mitigated by not letting the amount in an open position fall below MinimumPoolLiquidity. This can be enforced as follows in the remove_liquidity() function.

ensure!(
	updated_position.amount >= T::MinimumPoolLiquidity::get() || updated_position.amount == 0,
	Error::<T>::InsufficientLiquidity
);

Assessed type

DoS

#0 - c4-pre-sort

2024-03-03T09:23:13Z

0xRobocop marked the issue as primary issue

#1 - c4-pre-sort

2024-03-03T09:23:34Z

0xRobocop marked the issue as sufficient quality report

#2 - c4-pre-sort

2024-03-03T19:16:59Z

0xRobocop marked the issue as high quality report

#3 - c4-sponsor

2024-03-05T16:27:32Z

enthusiastmartin (sponsor) disputed

#4 - enthusiastmartin

2024-03-05T16:27:48Z

This is publicly known issue, raised by our team:

https://github.com/galacticcouncil/HydraDX-node/issues/674

#5 - OpenCoreCH

2024-03-08T10:59:42Z

While the sponsor was already aware of the issue, it was not ruled out as a known issue in the contest description and can therefore be not deemed out of scope.

#6 - c4-judge

2024-03-08T10:59:46Z

OpenCoreCH marked the issue as selected for report

#7 - QiuhaoLi

2024-03-14T05:44:10Z

Hi @OpenCoreCH and @enthusiastmartin, thanks for the review. I have a question (not a dispute): Haven't we already limited the storage usage with gas fees (weight)? In omnipool/src/weights.rs:

	/// Storage: `Omnipool::Positions` (r:0 w:1)  <===
	/// Proof: `Omnipool::Positions` (`max_values`: None, `max_size`: Some(100), added: 2575, mode: `MaxEncodedLen`)
	fn add_liquidity() -> Weight {
		// Proof Size summary in bytes:
		//  Measured:  `3919`
		//  Estimated: `8739`
		// Minimum execution time: 220_969_000 picoseconds.
		Weight::from_parts(222_574_000, 8739)
			.saturating_add(T::DbWeight::get().reads(20))
			.saturating_add(T::DbWeight::get().writes(14)) // <===
	}

As we can see, the user will be charged the fees of storage writes for minting new positions. So if an attack tries to bloat the storage, it will suffer from the corresponding fees.

#8 - J4X-98

2024-03-14T06:09:16Z

Hi @QiuhaoLi ,

The costs for a protocol on Polkadot consist of 2 kinds of costs. The computation costs are forwarded to the user using the weights and the storage costs, which have to be handled by the protocol themselves.

The attacker is correctly charged for the storage instruction (computation cost) but is able to force the protocol to incur the constant cost of maintaining the positions (storage cost). This storage cost should only be incurred by the protocol for serious positions, which is why they have set a minimum of 1 million tokens. From positions of that size, they can recoup their storage cost through other fees. As one can see in the issue this can be circumvented and the protocol will not be able to recoup the storage costs through fees on dust positions leading to a potential DOS. This can happen if the storage is flooded with dust positions, leading to massive storage costs that the protocol can not recoup through fees due to the insufficient size of each position.

As the sponsor has acknowledged this is a valid issue that they are trying to fix internally, so I don't see why this should be invalidated.

#9 - QiuhaoLi

2024-03-14T06:24:39Z

@J4X-98 Hi, thanks a lot for the explanations! I once thought about the cost of positions and decided it has been charged as fees just like Ethereum storage, which seems wrong. As I said this is not a dispute, just a question, nice finding!

Findings Information

🌟 Selected for report: J4X

Also found by: tsvetanovv

Labels

bug
2 (Med Risk)
primary issue
selected for report
:robot:_51_group
M-09

Awards

3416.8235 USDC - $3,416.82

External Links

Lines of code

https://github.com/code-423n4/2024-02-hydradx/blob/main/HydraDX-node/pallets/omnipool/src/lib.rs#L1541

Vulnerability details

Bug Description

The HydraDx protocol includes an oracle. This oracle generates prices, based upon the information it receives from its sources (of which Omnipool is one). The Omnipool provides information to the oracle through the on_liquidity_changed and on_trade hooks. Whenever a trade happens or the liquidity in one of the pools changes the corresponding hooks need to be called with the updated values.

The Omnipool contract also includes the remove_token() function. This function can only be called by the authority and can be only called on an asset which is FROZEN and where all the liquidity shares are owned by the protocol.

ensure!(asset_state.tradable == Tradability::FROZEN, Error::<T>::AssetNotFrozen);
ensure!(
	asset_state.shares == asset_state.protocol_shares,
	Error::<T>::SharesRemaining
);

When the function gets called it transfers all remaining liquidity to the beneficiary and removes the token. This is a change in liquidity in the Omnipool. The functionality in terms of liquidity change is similar to the withdraw_protocol_liquidity() where the protocol also withdraws liquidity in the form of protocol_shares from the pool. When looking at the withdraw_protocol_liquidity() function, one can see that it calls the on_liquidity_changed hook at the end, so that the oracle receives the information about the liquidity change.

T::OmnipoolHooks::on_liquidity_changed(origin, info)?;

Unfortunately, the remove_token() function does not call this hook, keeping the oracle in an outdated state. As the token is removed later on, the oracle will calculate based on liquidity that does not exist anymore in the Omnipool.

Impact

The issue results in the oracle receiving incorrect information and calculating new prices, based on an outdated state of the Omnipool.

Proof of Concept

The issue can be viewed when looking at the code of remove_token() where one can see that no call to the hook happens.

#[pallet::call_index(12)]
#[pallet::weight(<T as Config>::WeightInfo::remove_token())]
#[transactional]
pub fn remove_token(origin: OriginFor<T>, asset_id: T::AssetId, beneficiary: T::AccountId) -> DispatchResult {
	T::AuthorityOrigin::ensure_origin(origin)?;
	let asset_state = Self::load_asset_state(asset_id)?;

	// Allow only if no shares are owned by LPs and the asset is frozen.
	ensure!(asset_state.tradable == Tradability::FROZEN, Error::<T>::AssetNotFrozen);
	ensure!(
		asset_state.shares == asset_state.protocol_shares,
		Error::<T>::SharesRemaining
	);
	// Imbalance update
	let imbalance = <HubAssetImbalance<T>>::get();
	let hub_asset_liquidity = Self::get_hub_asset_balance_of_protocol_account();
	let delta_imbalance = hydra_dx_math::omnipool::calculate_delta_imbalance(
		asset_state.hub_reserve,
		I129 {
			value: imbalance.value,
			negative: imbalance.negative,
		},
		hub_asset_liquidity,
	)
	.ok_or(ArithmeticError::Overflow)?;
	Self::update_imbalance(BalanceUpdate::Increase(delta_imbalance))?;

	T::Currency::withdraw(T::HubAssetId::get(), &Self::protocol_account(), asset_state.hub_reserve)?;
	T::Currency::transfer(asset_id, &Self::protocol_account(), &beneficiary, asset_state.reserve)?;
	<Assets<T>>::remove(asset_id);
	Self::deposit_event(Event::TokenRemoved {
		asset_id,
		amount: asset_state.reserve,
		hub_withdrawn: asset_state.hub_reserve,
	});
	Ok(())
}

Tools Used

Manual Review

Recommended Mitigation Steps

The issue can be mitigated by forwarding the updated asset state to the oracle by calling the on_liquidity_changed hook.

Assessed type

Oracle

#0 - c4-pre-sort

2024-03-03T08:38:37Z

0xRobocop marked the issue as duplicate of #141

#1 - c4-judge

2024-03-07T22:23:48Z

OpenCoreCH marked the issue as unsatisfactory: Invalid

#2 - J4X-98

2024-03-13T02:06:25Z

Hi @OpenCoreCH ,

thank you very much for reviewing this contest. This issue has been deemed as invalid due to a comment by the sponsor on the primary issue #141. #141 describes that in the functions sacrifice_position() and remove_token() a hook call to on_liquidity_changed is missing. The sponsor has disputed this with the claim that in none of those functions, the liquidity gets changed, which is true for sacrifice_position() but not for remove_token(). In sacrifice_position(), the sacrificed positions' ownership is transferred to the protocol but the liquidity does not change.

The same is not the case for the remove_token() function. As one can see in the following code snippet, the function transfers out all liquidity that is owned by protocol shares to a beneficiary, changing the liquidity in the pool.

T::Currency::transfer(asset_id, &Self::protocol_account(), &beneficiary, asset_state.reserve)?;

The function documentation also mentions the liquidity change.

So contrary to the comment of the sponsor, not only does the token get removed but also the liquidity changes, as the protocol-owned liquidity is sent to the beneficiary. This should result in a call to the hook so that the circuit breaker and the oracle get accordingly updated (and trigger at the right values). This could for example lead to an issue if we have a maximum liquidity change per block of 100 tokens chosen in our circuit breaker and a token gets removed with 90 tokens of protocol liquidity being withdrawn. A later call withdrawing 20 liquidity would incorrectly pass as the earlier withdrawn liquidity is not accounted for due to the missing hook call. This would undermine the security measure of the circuit breaker as the limits are not correctly enforced. Additionally, due to the missing liquidity update, the oracle will be outdated too.

I would like to mention that my issue is the only issue that fully and correctly documents the problem, as #141 is reporting an invalid additional issue and also recommends an incorrect mitigation of increasing the liquidityInBlock in sacrifice_position().

#3 - OpenCoreCH

2024-03-15T09:29:57Z

Thanks for your comment. After looking at it again, remove_token indeed changes the liquidity like add_token does. While add_token calls on_liquidity_changed, remove_token does not, which can lead to inconsistencies.

#4 - c4-judge

2024-03-15T09:30:14Z

OpenCoreCH removed the grade

#5 - c4-judge

2024-03-15T09:30:22Z

OpenCoreCH marked the issue as not a duplicate

#6 - c4-judge

2024-03-15T09:30:58Z

OpenCoreCH marked the issue as primary issue

#7 - c4-judge

2024-03-15T09:31:55Z

OpenCoreCH marked the issue as selected for report

Findings Information

🌟 Selected for report: castle_chain

Also found by: J4X, QiuhaoLi, oakcobalt

Labels

bug
2 (Med Risk)
satisfactory
:robot:_16_group
duplicate-22

Awards

1064.4719 USDC - $1,064.47

External Links

Lines of code

https://github.com/code-423n4/2024-02-hydradx/blob/main/HydraDX-node/pallets/omnipool/src/lib.rs#L743-L753

Vulnerability details

Bug Description

The HydraDx Protocol's Omnipool functionality is designed to safeguard users against front-running attacks during liquidity withdrawals. It employs a check against price deviations exceeding 1% from the oracle-provided average price. This mechanism is intended to revert transactions if such deviations occur, potentially due to front-running. However, the introduction of a safe_withdrawal boolean parameter compromises this protection under certain conditions. The safe_withdrawal flag is set to true when the authority disables market trading, based on the assumption that front-running is not possible without active trading.

pub(crate) fn is_safe_withdrawal(&self) -> bool {
	*self == Tradability::ADD_LIQUIDITY | Tradability::REMOVE_LIQUIDITY || *self == Tradability::REMOVE_LIQUIDITY
}

The flaw arises if an attacker identifies transactions aimed at withdrawing liquidity alongside an authority's decision to suspend trading. By front-running these transactions and manipulating the price before trading is halted, the attacker can exploit the safe_withdrawal condition. This allows the withdrawal to proceed without doing the 1% deviation check, leading to potential greater losses.

if !safe_withdrawal {
	T::PriceBarrier::ensure_price(
		&who,
		T::HubAssetId::get(),
		asset_id,
		EmaPrice::new(asset_state.hub_reserve, asset_state.reserve),
	)
	.map_err(|_| Error::<T>::PriceDifferenceTooHigh)?;
}

This mechanism is predicated on the notion that disabling trading removed the possibility of front-running. Nonetheless, this overlooks the brief window wherein transactions are visible but unexecuted, allowing an attacker to manipulate prices prior to the trading halt.

Impact

This oversight reverses the intended effect of the safe_withdrawal mechanism. Far from offering protection against front-running, it facilitates more severe exploitation by attackers during the interim between the detection of liquidity withdrawal transactions and the enforcement of trading restrictions.

Proof of Concept

Consider a scenario where an attacker observes a pending liquidity withdrawal transaction together with an authority call to disable trading. The attacker can front-run both transactions to significantly alter the market price before the trading halt is enacted. Once trading is suspended and the safe_withdrawal flag is engaged, the original withdrawal transaction proceeds without the protective price deviation check.

#[test]
fn safe_withdrawal_allows_for_frontrunning() {
	ExtBuilder::default()
		.with_endowed_accounts(vec![
			(Omnipool::protocol_account(), DAI, 1000 * ONE),
			(Omnipool::protocol_account(), HDX, NATIVE_AMOUNT),
			(LP2, 1_000, 2000 * ONE),
			(LP2, DAI, 1000 * ONE),
			(LP1, 1_000, 5000 * ONE),
		])
		.with_initial_pool(FixedU128::from_float(0.5), FixedU128::from(1))
		.with_token(1_000, FixedU128::from_float(0.65), LP2, 2000 * ONE)
		.with_min_withdrawal_fee(Permill::from_float(0.01))
		.build()
		.execute_with(|| {

			//--------------------- SETUP -----------------------
			let liq_added = 400 * ONE;
			let current_position_id = <NextPositionId<Test>>::get();

			//LP1 adds liquidity
			assert_ok!(Omnipool::add_liquidity(RuntimeOrigin::signed(LP1), 1_000, liq_added));

			//---------------------- ATTACK -----------------------
			//Malicious actor sees that the asset will be restricted soon, and that LP1 wants to remove liquidity in the mempool
			//Malicious actor frontruns both transactions and manipulates the price before the pool gets restricted

			//Malicious actor frontruns the calls and deviates the price into a territory above the 1% threshold
			EXT_PRICE_ADJUSTMENT.with(|v| {
				*v.borrow_mut() = (3, 100, true);
			});

			//The asset gets restricted (potentially due to market risk etc.) by the authority
			assert_ok!(Omnipool::set_asset_tradable_state(
				RuntimeOrigin::root(),
				1_000,
				Tradability::ADD_LIQUIDITY | Tradability::REMOVE_LIQUIDITY
			));

			let position = Positions::<Test>::get(current_position_id).unwrap();

			//LP1's removal of liquidity now also passes the mempool and is executed
			assert_ok!(Omnipool::remove_liquidity(
				RuntimeOrigin::signed(LP1),
				current_position_id,
				position.shares,
			));

			//LP1's removal was now executed at a price deviation worse than the 1%, which it should intentionally be protected against.
		});
}

To run the test it needs to be added to the omnipool/src/tests/remove_liquidity.rs file.

Tools Used

Manual Review

Recommended Mitigation Steps

To address this vulnerability, it's advisable to eliminate the safe_withdrawal flag, ensuring continuous protection against price manipulation and front-running, regardless of trading status. This adjustment will maintain the integrity of the liquidity withdrawal process, safeguarding users from potential exploitation.

Assessed type

MEV

#0 - c4-pre-sort

2024-03-03T17:35:07Z

0xRobocop marked the issue as duplicate of #158

#1 - c4-judge

2024-03-07T21:12:30Z

OpenCoreCH marked the issue as satisfactory

#2 - J4X-98

2024-03-18T12:32:29Z

Hey @OpenCoreCH,

I just saw that the accepted issues were changed during PJQA. This is a dup of the accepted #22 and was incorrectly duplicated to #93. I know this is very late but #22 was not accepted during PJQA when I commented on all issues, so I didn't flag the wrong deduplication as I though it was considered invalid. I kindly ask you to consider it.

Thanks for all your effort and best, J4X

#3 - c4-judge

2024-03-18T13:25:57Z

OpenCoreCH marked the issue as not a duplicate

#4 - c4-judge

2024-03-18T13:26:06Z

OpenCoreCH marked the issue as duplicate of #22

Awards

150.1907 USDC - $150.19

Labels

bug
grade-a
QA (Quality Assurance)
sufficient quality report
Q-04

External Links

Low

[L-01] Formula for $\Delta b$ is not implemented according to documentation

OmipoolMath Line 344

Issue Description:

According to the Omnipool documentation, the formula for calculating $\Delta b$ (the amount of protocol shares) in the case of removed liquidity with the variables Pi (current price), $P\alpha$ (old price) and $S\alpha$ (shares removed) is:

ΔB=max(Pi−PαPi+Pα∗(−Sα),0)\Delta B = max(\frac{Pi-P\alpha}{Pi+P\alpha} * (- S\alpha),0)

This is not the way the function is implemented in the code. The function is implemented in code as:

Δb=Pα∗currentReserveOfAsset−currentHubReservesPα∗currentReserveOfAsset+currentHubReserves∗Sα+1\Delta b = \frac{P\alpha * currentReserveOfAsset - currentHubReserves}{P\alpha * currentReserveOfAsset + currentHubReserves} * S\alpha + 1

This could be further simplified by dividing the upper and lower term of the fraction by currentReserveOfAsset and then splitting at the -/+.

Δb=Pα∗currentReserveOfAssetcurrentReserveOfAsset−currentHubReservescurrentReserveOfAssetPα∗currentReserveOfAssetcurrentReserveOfAsset+currentHubReservescurrentReserveOfAsset∗Sα+1\Delta b = \frac{P\alpha * \frac{currentReserveOfAsset}{currentReserveOfAsset} - \frac{ currentHubReserves}{currentReserveOfAsset}}{P\alpha * \frac{currentReserveOfAsset}{currentReserveOfAsset} + \frac{ currentHubReserves}{currentReserveOfAsset}} * S\alpha + 1 Now this can be simplified by removing the $\frac{currentReserveOfAsset}{currentReserveOfAsset}$ as it is equal to 1 and replacing $\frac{ currentHubReserves}{currentReserveOfAsset}$ with Pi as it would be the way to calculate the current price.

Δb=Pα−PiPα+Pi∗Sα+1\Delta b = \frac{P\alpha - Pi}{P\alpha + Pi} * S\alpha + 1 If one now adds the negation added in the original form one ends up with a formula pretty close to the documented one.

Δb=Pi−PαPi+Pα∗(−Sα)+1\Delta b = \frac{Pi - P\alpha }{Pi + P\alpha} * (- S\alpha) + 1 As we know that $Pi < P\alpha$ and that $S\alpha$ is a positive value, the result of $\frac{Pi - P\alpha }{Pi + P\alpha} * - S\alpha$ could potentially go very close to zero, but could never be negative. So by adding the +1 at the end, the minimum we could reach (with rounding) is 1 not 0.

Recommended Mitigation Steps

As rounding in the favor of the protocol (by adding 1) should be intended behavior, the documentation of the formula is incorrect and needs to be adapted. The correct implemented formula is:

ΔB=max(Pi−PαPi+Pα∗(−Sα),1)\Delta B = max(\frac{Pi-P\alpha}{Pi+P\alpha} * (- S\alpha),1)

This formula should also be documented.

[L-02] Missing check for swap to 0 when selling hub asset

omnipool/src/lib.rs Line 1704

Issue Description:

The omnipool swapping mechanism uses the sell() function so that a user can swap tokens and wants to specify how many token he wants to sell. One of the safeguards in this functions is the check if the user would get 0 tokens out and throwing an ZeroAmountOut error in that case, as this would mean a 100% loss on the swap for the user.

ensure!(
	*state_changes.asset_out.delta_reserve > Balance::zero(),
	Error::<T>::ZeroAmountOut
);

When the user wants to sell the Hub Asset Token for another token, the underlying sell_hub_asset() function is used. Unfortunately this function misses to do the mentioned check and does not return the ZeroAmountOut error on state_changes.asset.delta_reserve == 0. This leads to potential 100% losses for users on swaps.

Recommended Mitigation Steps

The issue can be mitigated by adding the same check that is used in the sell() function to the sell_hub_asset() function.

ensure!(
	*state_changes.asset.delta_reserve > Balance::zero(),
	Error::<T>::ZeroAmountOut
);

[L-03] Missing balance check in add_liquidity_shares()

StableSwap Line 512

**Issue Description:

When a user deposits funds into the protocol, either for a swap or to add liquidity, the users balance must first be checked. In the add_liquidty() function this is checked as follows.

ensure!(
	T::Currency::free_balance(asset.asset_id, who) >= asset.amount,
	Error::<T>::InsufficientBalance
);

Unfortunately the do_add_liquidity_shares() function does not implement any check on the balance.

**Recommended Mitigation Steps

The issue can be mitigated by implementing the same check in the do_add_liquidity_shares() function.

[L-04] Incorrect error returned for double adding of liquidity

StableSwap Line 1017

**Issue Description:

The StableSwap allows users to add liquidity for multiple assets at once. This is done by passing an array of assets and amount tuples to the do_add_liquidity() function. When an asset is more than once in that array, an error is correctly thrown. Unfortunately the error that is thrown is IncorrectAssets which is described as "Creating a pool with same assets or less than 2 assets is not allowed.".

Recommended Mitigation Steps:

The issue can be mitigated by adding a custom error called DuplicateEntry for this error case.

[L-05] Incorrect access for Authority

Omnipool/lib.rs Line156

Issue Description:

The Omnipools documentation states that the Authorities callable functions as

// Origin that can add token, refund refused asset and withdraw protocol liquidity.

Unfortunately by implementation the AuthorityOrigin is also able to call the function to remove tokens using `remove_token().

Recommended Mitigation Steps:

If it is intended that the AuthorityOrigin can remove tokens it must be added to the documentation. Otherwise the requirement needs to be removed/changed.

Non-Critical

[NC-01] Incorrect documentation of Error in add_liquidity

omnipool/src/lib.rs Line 560

Issue Description:

The documentation of the add_liquidity() function states:

//Asset weight cap must be respected, otherwise `AssetWeightExceeded` error is returned.

Unfortunately this is incorrect as the error is called AssetWeightCapExceeded not AssetWeightExceeded.

Recommended Mitigation Steps

The issue can be mitigated by correcting the documentation:

//Asset weight cap must be respected, otherwise `AssetWeightCapExceeded` error is returned.

[NC-02] Typo in documentation of calculate_remove_liquidity_state_changes

math/omnipool/math.rs Line 310

Issue Description:

The documentation of the calculate_remove_liquidity_state_changes() function includes a typo on the word liqudiity.

/// Calculate delta changes of remove liqudiity given current asset state and position from which liquidity should be removed.

Recommended Mitigation Steps

The issue can be mitigated by correcting the documentation:

/// Calculate delta changes of remove liquidity given current asset state and position from which liquidity should be removed.```

[NC-03] Incorrect ratio mentioned in buy_asset_for_hub_asset()

omnipool/src/lib.rs Line 1833

**Issue Description:

In the calculation if the MaxOutRatio is followed in the buy_asset_for_hub_asset() function, the comment states the wrong case for the error as failing if the MaxInRatio is zero.

ensure!(
	amount
		<= asset_state
			.reserve
			.checked_div(T::MaxOutRatio::get())
			.ok_or(ArithmeticError::DivisionByZero)?, // Note: this can only fail if MaxInRatio is zero.
	Error::<T>::MaxOutRatioExceeded
);

Recommended Mitigation Steps

The Issue can be mitigated by correcting the comment.

ensure!(
	amount
		<= asset_state
			.reserve
			.checked_div(T::MaxOutRatio::get())
			.ok_or(ArithmeticError::DivisionByZero)?, // Note: this can only fail if MaxOutRatio is zero.
	Error::<T>::MaxOutRatioExceeded
);

[NC-04] Incorrect ratio mentioned in sell_hub_asset()

omnipool/src/lib.rs Line 1756

**Issue Description:

In the calculation if the MaxOutRatio is followed in the sell_hub_asset() function, the comment states the wrong case for the error as failing if the MaxInRatio is zero.

ensure!(
	*state_changes.asset.delta_reserve
		<= asset_state
			.reserve
			.checked_div(T::MaxOutRatio::get())
			.ok_or(ArithmeticError::DivisionByZero)?, // Note: this can only fail if MaxInRatio is zero.
	Error::<T>::MaxOutRatioExceeded
);

Recommended Mitigation Steps

The Issue can be mitigated by correcting the comment.

ensure!(
	*state_changes.asset.delta_reserve
		<= asset_state
			.reserve
			.checked_div(T::MaxOutRatio::get())
			.ok_or(ArithmeticError::DivisionByZero)?, // Note: this can only fail if MaxOutRatio is zero.
	Error::<T>::MaxOutRatioExceeded
);

[NC-05] Incorrect documentation of calculate_shares_for_amount()

math/src/stableswap/math.rs Line 172

Issue Description:

The documentation of the calculate_shares_for_amount() incorrectly states:

/// Calculate amount of shares to be given to LP after LP provided liquidity of one asset with given amount.

This is incorrect as the function is used to calculate the shares that a user will have to burn when withdrawing liquidity.

Recommended Mitigation Steps

The issue can be fixed by adapting the comment as follows:

/// Calculate amount of shares to be burned from LP after LP withdraws liquidity.

#0 - c4-pre-sort

2024-03-03T17:34:25Z

0xRobocop marked the issue as sufficient quality report

#1 - c4-judge

2024-03-08T19:37:22Z

OpenCoreCH marked the issue as grade-a

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter