Platform: Code4rena
Start Date: 17/02/2023
Pot Size: $38,600 USDC
Total HM: 5
Participants: 5
Period: 5 days
Judge: GalloDaSballo
Total Solo HM: 2
Id: 217
League: ETH
Rank: 4/5
Findings: 3
Award: $0.00
π Selected for report: 2
π Solo Findings: 2
π Selected for report: hihen
Also found by: 0x52, 0xsomeone, AkshaySrivastav, bin2chen
Data not available
Judge has assessed an item in Issue #18 as 2 risk. The relevant finding follows:
[L-01] changePayees can result in broken share count Description for (uint256 i; i < newPayees.length; i++) { if (newPayees[i] == address(0)) { revert Errors.CANNOT_SET_TO_ADDRESS_ZERO(); } if (newShares[i] == 0) { revert Errors.SHARE_CANNOT_BE_ZERO(); }
address payee = newPayees[i]; _payees.add(payee); _shares[payee] = newShares[i]; _totalShares += newShares[i]; emit PayeeAdded(payee, newShares[i]); }
When adding the newPayees to the payees set the contract fails to validate that each address in newPayees is unique. The results is that _totalShares will be incorrect if there is a repeat in newPayees.
Example: Assume newPayees = [Bob,Bob] and newShares = [5,5]. Since _payees.add(payee) is a non-reverting add, if the address already exists in the set it won't revert. The end result is that _totalShares == 10 but _shares[Bob] == 5. This mismatch causes incorrect distribution of rewards.
#0 - c4-judge
2023-03-01T11:03:24Z
GalloDaSballo marked the issue as duplicate of #13
#1 - c4-judge
2023-03-01T16:44:43Z
GalloDaSballo marked the issue as satisfactory
π Selected for report: 0x52
Data not available
MCAGRateFeed#getRate may return stale data
(, int256 answer,,,) = oracle.latestRoundData();
Classic C4 issue. getRate only uses answer but never checks the freshness of the data, which can lead to stale bond pricing data. Stale pricing data can lead to bonds being bought and sold on KUMASwap that otherwise should not be available. This would harm KIBToken holders as KUMASwap may accept bond with too low of a coupon and reduce rewards.
Manual Review
Validate that updatedAt
has been updated recently enough:
- (, int256 answer,,,) = oracle.latestRoundData(); + (, int256 answer,,updatedAt,) = oracle.latestRoundData(); + if (updatedAt < block.timestamp - MAX_DELAY) { + revert(); + } if (answer < 0) { return _MIN_RATE_COUPON; }
#0 - GalloDaSballo
2023-02-26T19:57:29Z
The Warden has shown how, the system will not check for a stale price
In times of high network congestion, liquidations or if the feed has any downtime, the protocol may end-up using a stale price which can leak value.
For these reasons, I agree with Medium Severity
#1 - c4-judge
2023-02-26T19:57:33Z
GalloDaSballo marked the issue as selected for report
#2 - c4-sponsor
2023-02-28T02:58:36Z
m19 marked the issue as disagree with severity
#3 - m19
2023-02-28T03:01:06Z
This issue is tricky: central bank rate updates are not expected more than a handful of times a year, at most. We failed to document this properly and the lack of a fresh updatedAt
check was on purpose.
If we were only transmitting rates when an actual rate change happens, it's impossible to determine how "fresh" the data should be, a central bank rate change could happen every 3 months or twice a year or any timeframe really. So should the staleness check be 90 days, 180 days etc?
We agree that the oracle transmitter could just repeat the current rate on a daily or weekly basis instead making a stale data check useful again.
Because central bank rates are not volatile we still disagree with the severity in this case.
#4 - akshaysrivastav
2023-03-03T05:41:39Z
Did the discussion conclude on this?
Also do the sponsors themselves manage the MCAG feeds? If yes, then this issue won't be as severe as exluding the updatedAt
timestamp for an external chainlink price feed. Highly unlikely that the sponsors will stale the feed but keep the rest of the protocol active.
@GalloDaSballo @m19
#5 - GalloDaSballo
2023-03-03T10:29:13Z
I empathize with the Sponsor's answer and agree that in a way the maximum delay for the check is hard to determine, however, it's important that we acknowledge that the smart contract cannot "defend itself" unless we offer some sort of constraint that avoids it leaking value incorrectly.
Will consult with a colleague although I believe that the finding is consistent with the definition of Medium Severity
#6 - GalloDaSballo
2023-03-03T19:11:45Z
In contrast to the front-run, which can be viewed as a nuance / technicality;
The lack of a check would allow the bond to be sold for a time that is not intended, this could also have KumaSwap accept bonds for which the yield is no longer competitive
I believe the observation from the sponsor to be valid the changes may be few and far between, however, because the contract relies on the oracle for it's decision making, and no check is there to ensure that the decision "makes sense";
Given the possibility of:
I believe Medium Severity to be the more appropriate of the options
#7 - GalloDaSballo
2023-03-06T19:28:33Z
The warden has added a comment in terms of suggested mitigation:
We agree that the oracle transmitter could just repeat the current rate on a daily or weekly basis instead making a stale data check useful again.
To quote the sponsor I believe this would be the best course of action. Even thought the rate does not change frequently, its valuable to have a heartbeat to confirm that all systems are working as intended. Better to experience potential downtime for buys/sells than to have systems unknowingly down during an important rate change.
π Selected for report: 0x52
Data not available
Selling bonds with coupons that are already accounted will fail unexpectedly
if (_coupons.length() == _maxCoupons) { revert Errors.MAX_COUPONS_REACHED(); }
The above lines will cause ALL bonds sales to revert when _coupons.length
has reached _maxCoupons
. Since bonds may share the same coupon
, the swap should continue to accept bonds with a coupon
that already exist in the _coupons
set.
Manual Review
sellBond should only revert if the max length has been reached and bond.coupon doesn't already exist:
- if (_coupons.length() == _maxCoupons) { + if (_coupons.length() == _maxCoupons && !_coupons.contains(bond.coupon)) { revert Errors.MAX_COUPONS_REACHED(); }
#0 - c4-sponsor
2023-02-28T02:53:13Z
m19 marked the issue as sponsor confirmed
#1 - m19
2023-02-28T02:53:36Z
Even though this scenario is unlikely to ever happen we have confirmed this issue in a test:
function test_sellBond_WithExistingCouponWhenMaxCouponsReached() external { _KUMASwap.sellBond(1); IKUMABondToken.Bond memory bond_ = _bond; for (uint256 i; i < 364; i++) { bond_.coupon = bond_.coupon + 1; _KUMABondToken.issueBond(address(this), bond_); _KUMASwap.sellBond(i + 2); } _KUMABondToken.issueBond(address(this), _bond); _KUMASwap.sellBond(365); }
We intend to fix this.
#2 - GalloDaSballo
2023-03-01T10:41:41Z
The warden has shown a way in which the system could stop selling coupons due to the handling of duplicate coupons, this relies on a specific condition which may not always happen, for this reason, in lack of an attack that can be used to break the functionality, I agree with Medium Severity
#3 - c4-judge
2023-03-01T10:41:45Z
GalloDaSballo marked the issue as selected for report
Data not available
for (uint256 i; i < newPayees.length; i++) { if (newPayees[i] == address(0)) { revert Errors.CANNOT_SET_TO_ADDRESS_ZERO(); } if (newShares[i] == 0) { revert Errors.SHARE_CANNOT_BE_ZERO(); } address payee = newPayees[i]; _payees.add(payee); _shares[payee] = newShares[i]; _totalShares += newShares[i]; emit PayeeAdded(payee, newShares[i]); }
When adding the newPayees to the payees set the contract fails to validate that each address in newPayees is unique. The results is that _totalShares will be incorrect if there is a repeat in newPayees.
Example: Assume newPayees = [Bob,Bob] and newShares = [5,5]. Since _payees.add(payee) is a non-reverting add, if the address already exists in the set it won't revert. The end result is that _totalShares == 10 but _shares[Bob] == 5. This mismatch causes incorrect distribution of rewards.
Require that _payees.add(payee) actually adds the address:
address payee = newPayees[i]; - _payees.add(payee); + require(_payees.add(payee)); _shares[payee] = newShares[i]; _totalShares += newShares[i];
if (!_bondReserve.contains(tokenId)) { revert Errors.INVALID_TOKEN_ID(); } IKUMAAddressProvider KUMAAddressProvider = _KUMAAddressProvider; if (IKUMABondToken(KUMAAddressProvider.getKUMABondToken()).getBond(tokenId).maturity > block.timestamp) { revert Errors.BOND_NOT_MATURED(); } _expiredBonds.add(tokenId);
expireBond only ever checks if the bond is currently in the reserve set but never checks if it is already part of the expired set
Revert if bond is already part of the expired set:
- if (!_bondReserve.contains(tokenId)) { + if (!_bondReserve.contains(tokenId) || _expiredBonds.contains(tokenId)) { revert Errors.INVALID_TOKEN_ID(); }
KUMASwap#setFees allows manager to set fees to whatever they wish. This includes fees values that would break the functionality of the contract.
Add reasonable bounds for the max fee allowed
#0 - GalloDaSballo
2023-02-27T10:24:49Z
[L-01] changePayees can result in broken share count Dup 13 [L-02] expireBond can be called on bonds that are already part of the _expiredBonds set L
[L-03] KUMASwap#setFees doesn't contain any bounds for fees L
#1 - GalloDaSballo
2023-03-01T11:03:44Z
1L from dups
3L
#2 - c4-judge
2023-03-01T11:17:19Z
GalloDaSballo marked the issue as grade-b