Sherlock contest - GreyArt's results

Decentralized exploit protection.

General Information

Platform: Code4rena

Start Date: 20/01/2022

Pot Size: $80,000 USDC

Total HM: 5

Participants: 37

Period: 7 days

Judge: Jack the Pug

Total Solo HM: 1

Id: 76

League: ETH

Sherlock

Findings Distribution

Researcher Performance

Rank: 1/37

Findings: 5

Award: $11,521.89

๐ŸŒŸ Selected for report: 5

๐Ÿš€ Solo Findings: 1

Findings Information

๐ŸŒŸ Selected for report: GreyArt

Labels

bug
documentation
2 (Med Risk)
disagree with severity

Awards

3287.1415 USDC - $3,287.14

External Links

Handle

GreyArt

Vulnerability details

Impact

When escalating claims, the documentation states that the protocol agent is required to pay and stake a certain amount for the process. If the covered protocol is proven correct, then the amount specified by the claim will be paid out. They will also receive the stake amount back in full. If the covered protocol's escalation is not successful, then the amount specified by the claim is not paid out and the stake amount is not returned.

The protocol agent is reasonably expected to pay the following:

  • The stake (BOND) and
  • UMAโ€™s final fee

In reality, the protocol agent will end up paying more, as we shall see in the proof of concept.

Proof of Concept

Let us assume the following:

  • BOND = 9600 as defined in SherlockClaimManager
  • umaFee = 400 (at the time of writing, this value has been updated to 1500 USDC: see [Store.computeFinalFee(usdc)](https://etherscan.io/address/0x54f44eA3D2e7aA0ac089c4d8F7C93C27844057BF#readContract)).

On invoking escalate(), the following amounts are required:

  1. BOND + umaFee = 9600 + 400 will be transferred to UMA when invoking requestAndProposePriceFor()
// Taken from https://github.com/UMAprotocol/protocol/blob/master/packages/core/contracts/oracle/implementation/SkinnyOptimisticOracle.sol
// Only relevant lines are referenced
uint256 finalFee = _getStore().computeFinalFee(address(currency)).rawValue;
request.finalFee = finalFee;
totalBond = request.bond.add(request.finalFee);
if (totalBond > 0) currency.safeTransferFrom(msg.sender, address(this), totalBond);
  1. Another BOND + umaFee = 9600 + 400 will be transfered when invoking disputePriceFor()
// Taken from https://github.com/UMAprotocol/protocol/blob/master/packages/core/contracts/oracle/implementation/SkinnyOptimisticOracle.sol#L389-L390
totalBond = request.bond.add(request.finalFee);
if (totalBond > 0) request.currency.safeTransferFrom(msg.sender, address(this), totalBond);

However, whatโ€™s important to note is that UMA will โ€œburnโ€ half of the BOND collected + final fee. This will go against the claim that the protocol agent will be able to reclaim his stake in full.

StoreInterface store = _getStore();

// Avoids stack too deep compilation error.
{
    // Along with the final fee, "burn" part of the loser's bond to ensure that a larger bond always makes it
    // proportionally more expensive to delay the resolution even if the proposer and disputer are the same
    // party.
    uint256 burnedBond = _computeBurnedBond(disputedRequest);

    // The total fee is the burned bond and the final fee added together.
    uint256 totalFee = request.finalFee.add(burnedBond);

    if (totalFee > 0) {
        request.currency.safeIncreaseAllowance(address(store), totalFee);
        _getStore().payOracleFeesErc20(address(request.currency), FixedPoint.Unsigned(totalFee));
    }
}

function _computeBurnedBond(Request memory request) private pure returns (uint256) {
  // burnedBond = floor(bond / 2)
  return request.bond.div(2);
}

We finally note that on settlement, the eventual payout is

// Winner gets:
// - Their bond back.
// - The unburned portion of the loser's bond: proposal bond (not including final fee) - burned bond.
// - Their final fee back.
// - The request reward (if not already refunded -- if refunded, it will be set to 0).
payout = request.bond.add(request.bond.sub(_computeBurnedBond(settledRequest))).add(request.finalFee).add(
    request.reward
);
request.currency.safeTransfer(disputeSuccess ? request.disputer : request.proposer, payout);

Hence, in reality, the protocol agent will only receive 9600 * 2 - 4800 + 400 = 14800 should the dispute be successful. We note that the burnt amount of 4800 / 2 + 400 = 5200 has been taken by UMA.

One can further verify this behaviour by looking at a past resolution of another protocol:

https://dashboard.tenderly.co/tx/main/0x0f03f73a2093e385146791e8f2739dbc04b39145476d6940776680243460100f/debugger?trace=0.6.1

The above example has a bond is 0.0075 ETH, with UMAโ€™s final fee being 0.2 ETH. We see that UMA takes 0.2 + 0.5 * 0.0075 = 0.02375 ETH.

Thus, we see that the protocol agent will be charged disproportionally to what is expected.

We suggest changing the parameters of requestAndProposePriceFor() to

UMA.requestAndProposePriceFor(
  UMA_IDENTIFIER, // Sherlock ID so UMA knows the request came from Sherlock
  claim.timestamp, // Timestamp to identify the request
  claim.ancillaryData, // Ancillary data such as the coverage agreement
  TOKEN, // USDC
  BOND, // While sherlock handles rewards on its own, we use the BOND as the reward
  // because using it as UMA's bond would result in 0.5 * BOND charged by UMA excluding final fee
  1, // Ideally 0, but 0 = final fee used. Hence, we set it to the next lowest 
  // possible value
  LIVENESS, // Proposal liveness
  address(sherlockCore), // Sherlock core address
  0 // price
);

where BOND becomes the reward and the actual bond for UMA is 1. Ideally, it should be set to 0, but if set as such, UMA interprets it to use the final fee as the bond amount instead.

[request.bond = bond != 0 ? bond : finalFee;](https://github.com/UMAprotocol/protocol/blob/master/packages/core/contracts/oracle/implementation/SkinnyOptimisticOracle.sol#L321)

This way, the actual amount required from the protocol agent is the BOND + 2 * (USDC wei + umaFee) for the process. He will additionally be returned his BOND + umaFee if his dispute is successful.

#0 - Evert0x

2022-02-03T21:09:34Z

Non critical as documentation is incorrect about this

#1 - jack-the-pug

2022-03-26T08:16:52Z

Downgrading to Med as it's mostly because the documentation is incorrect.

#2 - rcstanciu

2022-03-27T08:00:41Z

@jack-the-pug The docs have been updated to explain the correct burned amount.

#3 - jack-the-pug

2022-04-03T07:50:31Z

@jack-the-pug The docs have been updated to explain the correct burned amount.

Thank you @rcstanciu

While I agreed that the issue only impacts a small sum of users and the impact is not significant. And the root cause of this issue may not be a wrong implementation but actual wrong documentation.

However, I still tend to make this a Med rather than a Low for the following reasons:

  1. A wrong documentation is arguably indistinguishable from a wrong implementation that actually violates the intention of the design, especial from an outsider's pov;
  2. This write-up indicates a dedicated and in-depth effort of the warden by digging into the documentation and trying to understand the intention and cross-compare with the actual implementation to find any differences. As a judge, part of my job is to make sure that the wardens' findings are being rewarded in a just and fair manner.

Therefore, I'm keeping this as a Med and I encourage the future wardens to continue finding the inconsistency between the documentation and the implementation.

Keep up the good work! GreyArt is a great name btw.

Findings Information

๐ŸŒŸ Selected for report: hyh

Also found by: GreyArt, hack3r-0m

Labels

bug
duplicate
2 (Med Risk)

Awards

1972.2849 USDC - $1,972.28

External Links

Handle

GreyArt

Vulnerability details

Impact

As per the documentation, โ€œAfter 2 weeks without action on an unlocked position arbs can come in to arbRestake(id), 20% of the underlying USDC amount (principal + yield) is at risk for the owner of the position.โ€

While shares are redeemed for the arbitrager, the ownerโ€™s shares are not reduced in the addressShares mapping. This will cause an incorrect value to be returned when fetching the ownerโ€™s tokenBalanceOfAddress().

Issue is rated as low severity because there is no impact to funds.

Proof of Concept

Append the following lines into the Do case of arbRestake() in Sherlock.js.

// to make totalStakeShares non-zero after Carol redeems NFT
await meta(this.sherlock.initialStake(this.amount, 10, this.bob.address));
await timeTraveler.increaseTime((this.t2.time.add(weeks12)).toNumber());
await this.sherlock.connect(this.carol).redeemNFT(1);
console.log(`expect Carol's address to have 0 token balance (fully redeemed)`);
expect(await this.sherlock.tokenBalanceOfAddress(this.carol.address)).to.eq(0);

The initial state is Carol, the only party, minting 1 NFT. After arbRestake() is done, if Carol redeems that NFT, she is expected to have 0 token balance. This is however not the case because of her shares are not decremented in arbRestake(). Note that we mint another NFT prior to Carolโ€™s redeem so that totalStakeShares is non-zero. (tokenBalanceOfAddress() returns zero if totalStakeShares is zero).

Reduce the nftOwnerโ€™s addressShares in arbRestake().

function arbRestake(uint256 _id) ... {
	_arbReward = _redeemShares(_id, arbRewardShares, msg.sender);
	// Burn NFT owner's addressShares
	addressShares[nftOwner] -= arbRewardShares;
	...
}

#0 - CloudEllie

2022-02-21T19:19:05Z

Duplicate of #109

Findings Information

๐ŸŒŸ Selected for report: hyh

Also found by: GreyArt, harleythedog, pauliax

Labels

bug
duplicate
2 (Med Risk)

Awards

1331.2923 USDC - $1,331.29

External Links

Handle

GreyArt

Vulnerability details

Impact

If there are funds remaining in an old strategy, there is only 1 way to claim those funds which is through Sherlock.updateYieldStrategy() . It is quite an inconvenience to do this.

Create an additional function to allow anyone to call withdraw / withdraw all from an old strategy. The withdrawn funds should be sent to SherlockCore e.g.

function oldStrategyWithdrawAll(IStrategyManager _oldStrategy) external override {
    _oldStrategy.withdrawAll();
}

#0 - CloudEllie

2022-02-21T19:22:08Z

Duplicate of #76

Findings Information

๐ŸŒŸ Selected for report: GreyArt

Also found by: cccz

Labels

bug
1 (Low Risk)
sponsor acknowledged

Awards

1095.7138 USDC - $1,095.71

External Links

Handle

GreyArt

Vulnerability details

Impact

There are multiple benefits to deriving the SHER and USDC token addresses from Sherlock.sol / _sherlockPosition.

  • Minimises incorrect argument errors / less constructor arguments required
  • Removes the need for zero argument checks for these arguments (thus saving gas)
  • Guaranteed correctness of USDC and SHER token addresses (with the exception that theyโ€™re wrongly set in SherlockPosition). Should either address be incorrect (accidentally swapped for example), incorrect token approvals would be given, causing the contract to fail. The sale would have to be delayed because any deposited SHER tokens for the sale can only be withdrawn after SherClaim.claimableAt().

Expose the token and SHER addresses in Sherlock.sol, and allow SherBuy to fetch these values.

constructor(
	uint256 _stakeRate,
	uint256 _buyRate,
	ISherlock _sherlockPosition,
	address _receiver,
	ISherClaim _sherClaim
) {
	...
	// TODO: ISherlock to expose token() and sher() addresses
	usdc = _sherlockPosition.token();
	sher = _sherlockPosition.sher();
}

Findings Information

๐ŸŒŸ Selected for report: GreyArt

Labels

bug
1 (Low Risk)
resolved
sponsor confirmed

Awards

2434.9196 USDC - $2,434.92

External Links

Handle

GreyArt

Vulnerability details

Impact

Other relevant view functions like lockupEnd(), sherRewards() and tokenBalanceOf() revert for non-existent IDs, but viewRewardForArbRestake() doesnโ€™t.

Include the existence check in viewRewardForArbRestake().

if (!_exists(_tokenID)) revert NonExistent();

Findings Information

๐ŸŒŸ Selected for report: kenzo

Also found by: GreyArt

Labels

bug
duplicate
1 (Low Risk)

Awards

1095.7138 USDC - $1,095.71

External Links

Handle

GreyArt

Vulnerability details

Impact

The escalate() function performs a sanity check on _amount:

if (_amount < BOND) revert InvalidArgument();

However, the exact _amount required is actually 2 * (BOND + UMAโ€™s final fee). The first instance is for calling [UMA.requestAndProposePriceFor()](https://github.com/UMAprotocol/protocol/blob/master/packages/core/contracts/oracle/implementation/SkinnyOptimisticOracle.sol#L331) and the second for calling [UMA.disputePriceFor()](https://github.com/UMAprotocol/protocol/blob/master/packages/core/contracts/oracle/implementation/SkinnyOptimisticOracle.sol#L390).

UMAโ€™s final fee can be found by referencing the finder contract in the Optimistic Oracle: StoreInterface(finder.getImplementationAddress(OracleInterfaces.Store)).computeFinalFee(address(currency)).rawValue. Note that this value isnโ€™t fixed, and can be updated by UMA.

For simplicity and gas savings, a stricter sanity check would be to check that _amount >= 2 * BOND.

uint256 internal constant MIN_STAKE_AMOUNT = 2 * BOND;

function escalate(uint256 _claimID, uint256 _amount)
  external
  override
  nonReentrant
  whenNotPaused
{
		if (_amount < MIN_STAKE_AMOUNT) revert InvalidArgument();
		...
}

#0 - Evert0x

2022-02-09T20:08:10Z

#112

Findings Information

๐ŸŒŸ Selected for report: defsec

Also found by: 0x0x0x, Dravee, Fitraldys, GreyArt, Jujic, OriDabush, bobi, byterocket, gzeon, robee, saian

Labels

bug
duplicate
G (Gas Optimization)

Awards

5.1903 USDC - $5.19

External Links

Handle

GreyArt

Vulnerability details

Impact

As per the common issue raised, caching the array length outside a loop saves reading it on each iteration, as long as the array's length is not changed during the loop.

uint256 len = claimCallbacks.length;
for (uint256 i; i < len; i++) {
	if (claimCallbacks[i] == _callback) revert InvalidArgument();
}

#0 - jack-the-pug

2022-03-26T06:52:39Z

Dup #231

Findings Information

๐ŸŒŸ Selected for report: Dravee

Also found by: 0x0x0x, 0x1f8b, Fitraldys, GreyArt, haku, ych18, ye0lde

Labels

bug
duplicate
G (Gas Optimization)

Awards

11.8662 USDC - $11.87

External Links

Handle

GreyArt

Vulnerability details

Impact

Instead of doing if (X == true) or if (X == false), it is more gas efficient to do if (X) or if (!X).

Instances

Manager.sweep()

// Sends any remaining ETH to the receiver address (as long as receiver address is payable)
(bool success, ) = _receiver.call{ value: address(this).balance }('');
if (success == false) revert InvalidConditions();

SherClaim.claim()

// Only allow claim calls if claim period is active
if (!active()) revert InvalidState();

SherlockClaimManager.cleanUp()

if (_isCleanupState(_oldState) == false) revert InvalidState();

SherlockClaimManager.escalate()

// Can this claim be updated (based on its current state)? If no, revert
if (_isEscalateState(_oldState, updated) == false) revert InvalidState();

Manager.sweep()

// Sends any remaining ETH to the receiver address (as long as receiver address is payable)
(bool success, ) = _receiver.call{ value: address(this).balance }('');
if (!success) revert InvalidConditions();

SherClaim.claim()

// Only allow claim calls if claim period is active
if (!active()) revert InvalidState();

SherlockClaimManager.cleanUp()

if (!_isCleanupState(_oldState)) revert InvalidState();

SherlockClaimManager.escalate()

// Can this claim be updated (based on its current state)? If no, revert
if (!_isEscalateState(_oldState, updated)) revert InvalidState();

#0 - jack-the-pug

2022-03-26T07:34:35Z

Dup #132

Findings Information

๐ŸŒŸ Selected for report: GreyArt

Also found by: Tomio

Labels

bug
G (Gas Optimization)

Awards

89.3132 USDC - $89.31

External Links

Handle

GreyArt

Vulnerability details

Impact

The _sweep() function will make a call to the _receiver regardless of any ETH balance in the contract.

// Sends any remaining ETH to the receiver address (as long as receiver address is payable)
(bool success, ) = _receiver.call{ value: address(this).balance }('');
if (success == false) revert InvalidConditions();

It would be more gas efficient to first return if ETH balance is zero, then make the call otherwise.

uint256 ethBal = address(this).balance;
// no balance to send, exit
if (ethBal == 0) return;
(bool success, ) = msg.sender.call{ value: ethBal }('');
// Note: another gas optimization is to use !success instead
if (success == false) revert InvalidConditions();

Findings Information

๐ŸŒŸ Selected for report: GreyArt

Labels

bug
G (Gas Optimization)

Awards

198.4737 USDC - $198.47

External Links

Handle

GreyArt

Vulnerability details

Impact

Gas optimisation.

A quick test on remix will show you that it is cheaper to assign than to add then assign so instead of doing _tvl += maxRewardsAvailable;, it is equivalent to replace it with _tvl = maxRewardsEndTVL;

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