Platform: Code4rena
Start Date: 06/01/2023
Pot Size: $210,500 USDC
Total HM: 27
Participants: 73
Period: 14 days
Judge: 0xean
Total Solo HM: 18
Id: 203
League: ETH
Rank: 52/73
Findings: 1
Award: $121.59
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: CodingNameKiki
Also found by: 0xA5DF, 0xAgro, 0xNazgul, 0xSmartContract, Aymen0909, BRONZEDISC, Bnke0x0, Breeje, Cyfrin, GalloDaSballo, HollaDieWaldfee, IceBear, IllIllI, MyFDsYours, RaymondFam, Ruhum, SaharDevep, Sathish9098, Soosh, Udsen, __141345__, brgltd, btk, carlitox477, chaduke, chrisdior4, cryptonue, delfin454000, descharre, hihen, joestakey, ladboy233, lukris02, luxartvinsec, peanuts, pedr02b2, rotcivegaf, shark, tnevler, yongskiws
121.587 USDC - $121.59
Number | Issues Details | Context |
---|---|---|
[L-01] | Low level calls with solidity version 0.8.14 and lower can result in optimiser bug | 3 |
[L-02] | Loss of precision due to rounding | 3 |
[L-03] | call() should be used instead of transfer() | 1 |
[L-04] | init() function can be called by anyone | 12 |
[L-05] | Payout may be too soon | 1 |
[L-06] | Integer overflow by unsafe casting | 11 |
[L-07] | Allows malleable SECP256K1 signatures | 3 |
[L-08] | Inconsistent validation of input | 1 |
Number | Issues Details | Context |
---|---|---|
[NC-01] | Use require() instead of assert() | 20 |
[NC-02] | Constants in comparisons should appear on the left side | 10 |
[NC-03] | Non-usage of specific imports | All Contracts |
[NC-04] | The nonReentrant modifier should occur before all other modifiers | 1 |
[NC-05] | Use a more recent version of solidity | All Contracts |
[NC-06] | Typos | 4 |
[NC-07] | Include @return parameters in NatSpec comments | All Contracts |
[NC-08] | Contracts should have full test coverage | All Contracts |
[NC-09] | Function writing does not comply with the Solidity Style Guide | All Contracts |
[NC-10] | Use bytes.concat() and string.concat() | 5 |
[NC-11] | Solidity compiler optimizations can be problematic | 1 |
[NC-12] | Mark visibility of init() functions as external | 2 |
[NC-13] | Value is not validated to be different than the existing one | 13 |
[NC-14] | Lack of event emit | 2 |
[NC-15] | Signature Malleability of EVM's ecrecover() | 3 |
[NC-16] | Critical changes should use-two step procedure | 1 |
[NC-17] | Add a timelock to critical functions | 15 |
[NC-18] | Use immutable instead of constant for values such as a call to keccak256() | 6 |
[NC-19] | Avoid shadowing inherited state variables | 1 |
[NC-20] | revert() Statements Should Have Descriptive Reason Strings | 5 |
The protocol is using low level calls with solidity version less then 0.8.14 which can result in optimizer bug.
Ref: https://medium.com/certora/overly-optimistic-optimizer-certora-bug-disclosure-2101e3f7994d
Consider upgrading to solidity 0.8.17
Loss of precision due to rounding in unstake, withdraw and stake functions.
uint256 newDraftRSR = (newTotalDrafts * FIX_ONE_256 + (draftRate - 1)) / draftRate;;
call()
should be used instead of transfer()
The use of the deprecated transfer()
function for a payable address will certainly make the transaction fail when:
The claimer smart contract does not implement a payable fallback function.
The claimer smart contract does implement a payable fallback which uses more than 2300 gas unit.
The claimer smart contract implements a payable fallback function that needs less than 2300 gas units but is called through proxy, raising the call's gas usage above 2300.
Some multisig wallets might also require higher gas than 2300.
A more detailed explanation can be found here.
function withdraw(uint256 wad) public { require(balanceOf[msg.sender] >= wad); balanceOf[msg.sender] -= wad; msg.sender.transfer(wad); emit Withdrawal(msg.sender, wad); }
Consider using call()
instead with the CEI pattern implemented correctly.
init()
function can be called by anybodyinit()
function can be called anybody when the contract is not initialized.
function init( IMain main_, uint48 tradingDelay_, uint192 backingBuffer_, uint192 maxTradeSlippage_, uint192 minTradeVolume_ ) external initializer { __Component_init(main_); __Trading_init(main_, maxTradeSlippage_, minTradeVolume_); assetRegistry = main_.assetRegistry(); basketHandler = main_.basketHandler(); distributor = main_.distributor(); rsr = main_.rsr(); rsrTrader = main_.rsrTrader(); rTokenTrader = main_.rTokenTrader(); rToken = main_.rToken(); stRSR = main_.stRSR(); setTradingDelay(tradingDelay_); setBackingBuffer(backingBuffer_); }
Add a DEPLOYER
address and require that only him can call the init()
function.
The first timestamp should be based on when rewards are first payed, so that an appropriate duration of the cycle occurs, rather than during deployment.
payoutLastPaid = uint48(block.timestamp);
Keep in mind that the version of solidity used, despite being greater than 0.8, does not prevent integer overflows during casting, it only does so in mathematical operations.
It is necessary to safely convert between the different numeric types.
endTime = uint48(block.timestamp) + auctionLength;
Use OpenZeppelin safeCast library.
SECP256K1
signaturesHere, the ecrecover()
method doesn't check the bytes32 s
range.
Homestead (EIP-2) added this limitation, however the precompile remained unaltered. The majority of libraries, including OpenZeppelin, do this check.
Use OpenZeppelin's ECDSA
library for signature verification.
While the setPrimeBasket()
function check that erc20s.length > 0
, the setBackupConfig()
does not.
if the caller calls setBackupConfig()
with erc20s.length = 0
by mistake, it will update the targetName
and config.max
and delete the conf.erc20s
array without updating it to a new value.
function setPrimeBasket(IERC20[] calldata erc20s, uint192[] calldata targetAmts) external governance { require(erc20s.length > 0, "cannot empty basket"); require(erc20s.length == targetAmts.length, "must be same length"); requireValidCollArray(erc20s); // Clean up previous basket config for (uint256 i = 0; i < config.erc20s.length; ++i) { delete config.targetAmts[config.erc20s[i]]; delete config.targetNames[config.erc20s[i]]; } delete config.erc20s; // Set up new config basket bytes32[] memory names = new bytes32[](erc20s.length); for (uint256 i = 0; i < erc20s.length; ++i) { // This is a nice catch to have, but in general it is possible for // an ERC20 in the prime basket to have its asset unregistered. require(assetRegistry.toAsset(erc20s[i]).isCollateral(), "token is not collateral"); require(0 < targetAmts[i], "invalid target amount; must be nonzero"); require(targetAmts[i] <= MAX_TARGET_AMT, "invalid target amount; too large"); config.erc20s.push(erc20s[i]); config.targetAmts[erc20s[i]] = targetAmts[i]; names[i] = assetRegistry.toColl(erc20s[i]).targetName(); config.targetNames[erc20s[i]] = names[i]; } emit PrimeBasketSet(erc20s, targetAmts, names); } function setBackupConfig( bytes32 targetName, uint256 max, IERC20[] calldata erc20s ) external governance { requireValidCollArray(erc20s); BackupConfig storage conf = config.backups[targetName]; conf.max = max; delete conf.erc20s; for (uint256 i = 0; i < erc20s.length; ++i) { // This is a nice catch to have, but in general it is possible for // an ERC20 in the backup config to have its asset altered. require(assetRegistry.toAsset(erc20s[i]).isCollateral(), "token is not collateral"); conf.erc20s.push(erc20s[i]); } emit BackupConfigSet(targetName, max, erc20s); }
Add this check to setBackupConfig()
function:
require(erc20s.length > 0);
require()
instead of assert()
Assert should not be used except for tests, require should be used. Prior to Solidity 0.8.0, pressing a confirm consumes the remainder of the process's available gas instead of returning it, as request()/revert()
did.
The big difference between the two is that the assert()
function when false, uses up all the remaining gas and reverts all the changes made. Meanwhile, a require()
statement when false, also reverts back all the changes made to the contract but does refund all the remaining gas fees we offered to pay. This is the most common Solidity function used by developers for debugging and error handling.
Assertion should be avoided even after solidity version 0.8.0, because its documentation states "The Assert function generates an error of type Panic(uint256)
. Code that works properly should never Panic, even on invalid external input. If this happens, you need to fix it in your contract. there's a mistake".
Use require()
instead of assert()
Constants in comparisons should appear on the left side, doing so will prevent typo bug.
assert(tradesOpen == 0 && !basketHandler.fullyCollateralized());
assert(0 == tradesOpen && !basketHandler.fullyCollateralized());
Using import declarations of the form import {<identifier_name>} from "some/file.sol"
avoids polluting the symbol namespace making flattened files smaller, and speeds up compilation.
The Solidity docs recommend specifying imported symbols explicitly.
Ref: https://docs.soliditylang.org/en/v0.8.15/layout-of-source-files.html#importing-other-source-files
Use specific imports syntax per solidity docs recommendation.
nonReentrant
modifier should occur before all other modifiersThis is a best-practice to protect against reentrancy in other modifiers
function settleTrade(IERC20 sell) external notPausedOrFrozen nonReentrant { ITrade trade = trades[sell]; if (address(trade) == address(0)) return; require(trade.canSettle(), "cannot settle yet"); delete trades[sell]; tradesOpen--; // == Interactions == (uint256 soldAmt, uint256 boughtAmt) = trade.settle(); emit TradeSettled(trade, trade.sell(), trade.buy(), soldAmt, boughtAmt); }
Use the nonReentrant
modifier first.
Use a solidity version of at least 0.8.12 to get string.concat()
instead of abi.encodePacked(<string>,<string>)
.
https://blog.soliditylang.org/2022/02/16/solidity-0.8.12-release-announcement/
Consider upgrading to 0.8.12.
/// @audit: lowLow /// lowLow should be nonzero when the asset might be worth selling
/// @audit: Mointain /// Mointain the overall backing policy; handout assets otherwise
/// @audit: iff // queue.right == left iff there are no more pending issuances in this queue
/// @audit: adjaacent // issuances, and so any particular issuance is actually the _difference_ between two adjaacent
@return
parameters in NatSpec commentsIf Return parameters are declared, you must prefix them with /// @return
. Some code analysis programs do analysis by reading NatSpec details, if they can't see the @return
tag, they do incomplete analysis.
Include the @return
argument in the NatSpec comments.
While 100% code coverage does not guarantee that there are no bugs, it often will catch easy-to-find bugs, and will ensure that there are fewer regressions when the code invariably has to be modified. Furthermore, in order to get full coverage, code authors will often have to re-organize their code so that it is more modular, so that each component can be tested separately, which reduces interdependencies between modules and layers, and makes for code that is easier to reason about and audit.
Line coverage percentage should be 100%.
Solidity Style Guide
Ordering helps readers identify which functions they can call and to find the constructor and fallback definitions easier. But there are contracts in the project that do not comply with this.
Functions should be grouped according to their visibility and ordered:
constructor()
receive()
fallback()
external / public / internal / private
view / pure
Follow Solidity Style Guide.
bytes.concat()
and string.concat()
bytes.concat()
vs abi.encodePacked(<bytes>,<bytes>)
string.concat()
vs abi.encodePacked(<string>,<string>)
Use bytes.concat()
and string.concat()
.
Protocol has enabled optional compiler optimizations in Solidity. There have been several optimization bugs with security implications. Moreover, optimizations are actively being developed. Solidity compiler optimizations are disabled by default, and it is unclear how many contracts in the wild actually use them.
Therefore, it is unclear how well they are being tested and exercised. High-severity security issues due to optimization bugs have occurred in the past. A high-severity bug in the emscripten-generated solc-js compiler used by Truffle and Remix persisted until late 2018. The fix for this bug was not reported in the Solidity CHANGELOG.
Another high-severity optimization bug resulting in incorrect bit shift results was patched in Solidity 0.5.6. More recently, another bug due to the incorrect caching of keccak256 was reported. A compiler audit of Solidity from November 2018 concluded that the optional optimizations may not be safe. It is likely that there are latent bugs related to optimization and that new bugs will be introduced due to future optimizations.
Exploit Scenario A latent or future bug in Solidity compiler optimizations—or in the Emscripten transpilation to solc-js—causes a security vulnerability in the contracts.
const settings = useEnv('NO_OPT') ? {} : { optimizer: { enabled: true, runs: 200 } }
Short term, measure the gas savings from optimizations and carefully weigh them against the possibility of an optimization-related bug. Long term, monitor the development and adoption of Solidity compiler optimizations to assess their maturity.
init()
functions as externalIf someone wants to extend via inheritance, it might make more sense that the overridden init()
function calls the internal {...}_init function, not the parent public init()
function.
External instead of public would give more the sense of the init()
functions to behave like a constructor (only called on deployment, so should only be called externally)
Security point of view, it might be safer so that it cannot be called internally by accident in the child contract
It might cost a bit less gas to use external over public
It is possible to override a function from external to public ("opening it up") but it is not possible to override a function from public to external ("narrow it down").
Ref: https://github.com/OpenZeppelin/openzeppelin-contracts/issues/3750
Change the visibility of init()
functions to external
While the value is validated to be in the constant bounds, it is not validated to be different than the existing one. Queueing the same value will cause multiple abnormal events to be emitted, will ultimately result in a no-op situation.
Add a require()
statement to check that the new value is different than the current one.
The below methods do not emit an event when the state changes, something that it's very important for dApps and users.
function setName(string calldata name_) external governance { name = name_; } function setSymbol(string calldata symbol_) external governance { symbol = symbol_; }
ecrecover()
The function calls the Solidity ecrecover()
function directly to verify the given signatures. However, the ecrecover()
EVM opcode allows malleable (non-unique) signatures and thus is susceptible to replay attacks.
Although a replay attack seems not possible for this contract, I recommend using the battle-tested OpenZeppelin's ECDSA library.
Use the ecrecover function from OpenZeppelin's ECDSA library for signature verification. (Ensure using a version > 4.7.3 for there was a critical bug >= 4.1.0 < 4.7.3).
The Main.sol
inherits Openzeppelin OwnableUpgradeable.sol
contract, which does not have a two-step procedure for critical changes.
/** * @dev Transfers ownership of the contract to a new account (`newOwner`). * Can only be called by the current owner. */ function transferOwnership(address newOwner) public virtual onlyOwner { require(newOwner != address(0), "Ownable: new owner is the zero address"); _transferOwnership(newOwner); } /** * @dev Transfers ownership of the contract to a new account (`newOwner`). * Internal function without access restriction. */ function _transferOwnership(address newOwner) internal virtual { address oldOwner = _owner; _owner = newOwner; emit OwnershipTransferred(oldOwner, newOwner); }
Consider adding two step procedure on the critical functions where the first is announcing a pending new owner and the new address should then claim its ownership.
It is a good practice to give time for users to react and adjust to critical changes. A timelock provides more guarantees and reduces the level of trust required, thus decreasing risk for users. It also indicates that the project is legitimate.
Consider adding a timelock to the critical changes.
While it doesn't save any gas because the compiler knows that developers often make this mistake, it's still best to use the right tool for the task at hand. There is a difference between constant variables and immutable variables, and they should each be used in their appropriate contexts. constants should be used for literal values written into the code, and immutable variables should be used for expressions, or values calculated in, or passed into the constructor.
Use immutable
instead of constants
In CTokenFiatCollateral.sol
there is a local variable named erc20
, but there is a state variable named erc20
in the inherited Asset.sol
with the same name. This use causes compilers to issue warnings, negatively affecting checking and code readability.
ICToken erc20 = ICToken(address(config.erc20)); referenceERC20Decimals = IERC20Metadata(erc20.underlying()).decimals();
IERC20Metadata public immutable erc20;
Avoid using variables with the same name.
revert()
Statements Should Have Descriptive Reason StringsThe correct and clear error description explains to the user why the function reverts, but the error descriptions below in the project are not self-explanatory. These error descriptions are very important in the debug features of DApps like Tenderly.
Error definitions should be added to the revert("...")
block.
#0 - c4-judge
2023-01-25T00:17:34Z
0xean marked the issue as grade-b