Tracer contest - cmichel's results

Build and trade with Tracerโ€™s Perpetual Swaps and gain leveraged exposure to any market in the world.

General Information

Platform: Code4rena

Start Date: 24/06/2021

Pot Size: $80,000 USDC

Total HM: 18

Participants: 12

Period: 7 days

Judge: cemozer

Total Solo HM: 11

Id: 16

League: ETH

Tracer

Findings Distribution

Researcher Performance

Rank: 2/12

Findings: 7

Award: $19,609.10

๐ŸŒŸ Selected for report: 8

๐Ÿš€ Solo Findings: 3

Findings Information

๐ŸŒŸ Selected for report: cmichel

Labels

bug
3 (High Risk)
sponsor confirmed

Awards

6710.1761 USDC - $6,710.18

External Links

Handle

cmichel

Vulnerability details

The Liquidation contract allows the liquidator to submit "bad" trade orders and the insurance reimburses them from the insurance fund, see Liquidation.claimReceipt. The function can be called with an orders array which does not check for duplicate orders. An attacker can abuse this to make a profit by liquidating themselves, making a small bad trade and repeatedly submitting this bad trade for slippage reimbursement.

Example:

  • Attacker uses two accounts, one as the liquidator and one as the liquidatee.
  • They run some high-leverage trades such that the liquidatee gets liquidated with the next price update. (If not cash out and make a profit this way through trading, and try again.)
  • Liquidator liquidates liquidatee
  • They now do two trades:
    • One "good" trade at the market price that fills 99% of the liquidation amount. The slippage protection should not kick in for this trade
    • One "bad" trade at a horrible market price that fills only 1% of the liquidation amount. This way the slippage protection kicks in for this trade
  • The liquidator now calls claimReceipt(orders) where orders is an array that contains many duplicates of the "bad" trade, for example 100 times. The calcUnitsSold function will return unitsSold = receipt.amountLiquidated and a bad avgPrice. They are now reimbursed the price difference on the full liquidation amount (instead of only on 1% of it) making an overall profit

This can be repeated until the insurance fund is drained.

Impact

The attacker has an incentive to do this attack as it's profitable and the insurance fund will be completely drained.

Disallow duplicate orders in the orders argument of claimReceipt. This should make the attack at least unprofitable, but it could still be a griefing attack. A quick way to ensure that orders does not contain duplicates is by having liquidators submit the orders in a sorted way (by order ID) and then checking in the calcUnitsSold for loop that the current order ID is strictly greater than the previous one.

#0 - CalabashSquash

2021-07-05T04:41:25Z

Valid issue. The recommended mitigation step would also work. :+1:

Findings Information

๐ŸŒŸ Selected for report: cmichel

Labels

bug
3 (High Risk)
sponsor confirmed
disagree with severity

Awards

6710.1761 USDC - $6,710.18

External Links

Handle

cmichel

Vulnerability details

The GasOracle uses two chainlink oracles (GAS in ETH with some decimals, USD per ETH with some decimals) and multiplies their raw return values to get the gas price in USD.

However, the scaling depends on the underlying decimals of the two oracles and could be anything. But the code assumes it's in 18 decimals.

"Returned value is USD/Gas * 10^18 for compatibility with rest of calculations"

There is a toWad function that seems to involve scaling but it is never used.

Impact**

If the scale is wrong, the gas price can be heavily inflated or under-reported.

Check chainlink.decimals() to know the decimals of the oracle answers and scale the answers to 18 decimals such that no matter the decimals of the underlying oracles, the latestAnswer function always returns the answer in 18 decimals.

#0 - raymogg

2021-07-05T03:09:15Z

Disagree with severity as while the statement that the underlying decimals of the oracles could be anything, we will be using production Chainlink feeds for which the decimals are known at the time of deploy.

This is still however an issue as you don't want someone using different oracles (eg non Chainlink) that have different underlying decimals and not realising that this contract will not support that.

#1 - kumar-ish

2021-07-15T13:26:58Z

Closed by accident

#2 - cemozerr

2021-07-18T22:11:59Z

Marking this a high-risk issue as it poses a big threat to users deploying their own markets

Findings Information

๐ŸŒŸ Selected for report: 0xRajeev

Also found by: a_delamo, cmichel, shw

Labels

bug
duplicate
2 (Med Risk)

Awards

366.8789 USDC - $366.88

External Links

Handle

cmichel

Vulnerability details

The Chainlink API (latestAnswer) used in the GasOracle oracle wrappers is deprecated:

This API is deprecated. Please see API Reference for the latest Price Feed API. Chainlink Docs

Impact**

It seems like the old API can return stale data. Checks similar to that of the new API using latestTimestamp and latestRoundare are needed. This could lead to stale prices according to the Chainlink documentation:

Add the recommended checks:

(
    uint80 roundID,
    int256 price,
    ,
    uint256 timeStamp,
    uint80 answeredInRound
) = chainlink.latestRoundData();
require(
    timeStamp != 0,
    โ€œChainlinkOracle::getLatestAnswer: round is not completeโ€
);
require(
    answeredInRound >= roundID,
    โ€œChainlinkOracle::getLatestAnswer: stale dataโ€
);
require(price != 0, "Chainlink Malfunctionโ€);

#0 - raymogg

2021-07-05T03:18:09Z

Duplicate of #145

Findings Information

๐ŸŒŸ Selected for report: s1m0

Also found by: 0xRajeev, JMukesh, Lucius, cmichel, pauliax, shw

Labels

bug
duplicate
2 (Med Risk)

Awards

152.8313 USDC - $152.83

External Links

Handle

cmichel

Vulnerability details

The ERC20.transfer() and ERC20.transferFrom() functions return a boolean value indicating success. This parameter should be checked for success. The Insurance.deposit and Insurace.withdraw functions dp not check the return value:

// deposit
collateralToken.transferFrom(msg.sender, address(this), rawTokenAmount);

// withdraw
collateralToken.transfer(msg.sender, rawTokenAmount);

Impact

Some tokens do not revert if the transfer failed but return false instead. Tokens that don't actually perform the transfer and return false are still counted as a correct transfer.

We recommend using OpenZeppelinโ€™s SafeERC20 versions with the safeTransfer and safeTransferFrom functions that handle the return value check as well as non-standard-compliant tokens.

#0 - raymogg

2021-07-05T03:24:55Z

Duplicate of #115

Findings Information

๐ŸŒŸ Selected for report: cmichel

Labels

bug
2 (Med Risk)
sponsor confirmed

Awards

2013.0528 USDC - $2,013.05

External Links

Handle

cmichel

Vulnerability details

The TracerPerpetualSwaps.settle function updates the user's last index to currentGlobalFundingIndex, however a comment states:

"// Note: global rates reference the last fully established rate (hence the -1), and not the current global rate. User rates reference the last saved user rate"

The code for the else branch also updates the last index to currentGlobalFundingIndex - 1 instead of currentGlobalFundingIndex.

if (accountBalance.position.base == 0) {
    // set to the last fully established index
    // @audit shouldn't this be global - 1 like below?
    accountBalance.lastUpdatedIndex = currentGlobalFundingIndex;
    accountBalance.lastUpdatedGasPrice = IOracle(gasPriceOracle).latestAnswer();
}

Impact

It might be possible that first-time depositors skip having to pay the first funding rate period as the accountLastUpdatedIndex + 1 < currentGlobalFundingIndex check will still return false when the funding rates are updated the next time.

Check if setting it to currentGlobalFundingIndex or to currentGlobalFundingIndex - 1 is correct.

Findings Information

๐ŸŒŸ Selected for report: cmichel

Also found by: gpersoon, tensors

Labels

bug
2 (Med Risk)

Awards

543.5243 USDC - $543.52

External Links

Handle

cmichel

Vulnerability details

The Trader contract accepts two signed orders and tries to match them. Once they are matched and become filled, they can therefore not be matched against other orders anymore.

This allows for a griefing attack where an attacker can deny any other user from trading by observing the mempool and front-running their trades by creating their own order and match it against the counter order instead.

Impact

A trader can be denied from trading. The cost of the griefing attack is that the trader has to match the order themselves, however depending on the liquidity of the order book and the spread, they might be able to do the counter-trade again afterwards, basically just paying the fees.

It could be useful if the attacker is a liquidator and is stopping a user who is close to liquidation from becoming liquid again.

This seems hard to circumvent in the current design. If the order book is also off-chain, the executeTrade could also be a bot-only function.

#0 - raymogg

2021-07-05T03:21:16Z

Duplicate of #123

#1 - loudoguno

2021-08-17T23:13:17Z

reopening to reflect judges sheet and final report

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