Platform: Code4rena
Start Date: 18/10/2023
Pot Size: $36,500 USDC
Total HM: 17
Participants: 77
Period: 7 days
Judge: MiloTruck
Total Solo HM: 5
Id: 297
League: ETH
Rank: 25/77
Findings: 3
Award: $226.38
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: 0xmystery
Also found by: 0x6d6164616e, 0xWaitress, 0xsurena, Tendency, ZanyBonzy, cryptothemex, hals, lsaudit, ni8mare, niki, phoenixV110, spark, tnquanghuy0512, twcctop
26.0735 USDC - $26.07
https://github.com/open-dollar/od-contracts/blob/f4f0246bb26277249c1d5afe6201d4d9096e52e6/src/contracts/oracles/CamelotRelayer.sol#L58 https://github.com/open-dollar/od-contracts/blob/f4f0246bb26277249c1d5afe6201d4d9096e52e6/src/contracts/oracles/UniV3Relayer.sol#L64
For the tokens with decimals greater than 18, CamelotRelayer.sol
and UniV3Relayer.sol
will revert because of the underflow.
Protocol performs normalization only for tokens with decimals <= 18
. It is assumed that the max number of decimals for each ERC-20 token is 18. However, this assumption is wrong. There are some ERC-20 tokens with decimals > 18
. For example: YAMv2 has 24 decimals. Protocol won't work with that tokens, because it will underflow and revert.
I've decided to evaluate this issue as Medium. Since protocol performs normalization for tokens with decimals <=18
, I've assumed that it's designated to work with every valid ERC-20 token. However, there are some valid ERC-20 tokens (with > 18
decimals) which won't work with the protocol - thus I've evaluated this issue as Medium.
Moreover, during the previous Code4rena contests, the similar findings had been evaluated as Medium:
The issue occurs in two instances:
multiplier = 18 - IERC20Metadata(_quoteToken).decimals();
multiplier = 18 - IERC20Metadata(_quoteToken).decimals();
Whenever _quoteToken
contains more than 18 decimals, there will be revert, because of the underflow.
Manual code review
Consider checking scenario when decimals are greater than 18 and normalize the value properly.
ERC20
#0 - c4-pre-sort
2023-10-26T03:42:55Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-10-26T03:43:10Z
raymondfam marked the issue as duplicate of #18
#2 - c4-pre-sort
2023-10-27T05:08:00Z
raymondfam marked the issue as duplicate of #323
#3 - c4-judge
2023-11-02T08:45:39Z
MiloTruck marked the issue as satisfactory
🌟 Selected for report: lsaudit
Also found by: 0xPsuedoPandit, 0xhacksmithh, Stormreckson, ZanyBonzy, klau5, tnquanghuy0512, twicek, xAriextz
106.3008 USDC - $106.30
Function transferSAFEOwnership()
is only callable by Only Vault721
: require(msg.sender == address(vault721), 'SafeMngr: Only Vault721');
and its purpose is to give the safe ownership to a dst
address.
However, transferSAFEOwnership()
does not reset safeCan
mapping which is used by safeAllowed
modifier.
This leads to some security issues. The modifier safeAllowed
is used to verify if user has permission to the safe. Multiple of functions which perform critical operations on the safe use this modifier to verify is user has permission to call that operation on a given safe.
Since transferSAFEOwnership()
transfer ownership to a different address, it should also reset all previously set privileges by the previous owner. However, safeCan
mapping is not being reset in transferSAFEOwnership()
Firstly, let's take a look how safeAllowed
modifier is defined:
modifier safeAllowed(uint256 _safe) { address _owner = _safeData[_safe].owner; if (msg.sender != _owner && safeCan[_owner][_safe][msg.sender] == 0) revert SafeNotAllowed(); _; }
For this PoC, let's consider user A
, who's the owner of safe 123.
He gives access to the safe to X
, Y
, so he calls:
allowSAFE(123, X, 1), allowSAFE(123, Y, 1)
;
function allowSAFE(uint256 _safe, address _usr, uint256 _ok) external safeAllowed(_safe) { address _owner = _safeData[_safe].owner; safeCan[_owner][_safe][_usr] = _ok; emit AllowSAFE(msg.sender, _safe, _usr, _ok); }
After calling this function, safeCan
mapping looks like this: safeCan[A][123][X] = 1
and safeCan[A][123][Y] = 1
.
Now, there's a call to transfer safe ownership to _dst
B: transferSAFEOwnership(123, B)
function transferSAFEOwnership(uint256 _safe, address _dst) external { require(msg.sender == address(vault721), 'SafeMngr: Only Vault721'); if (_dst == address(0)) revert ZeroAddress(); SAFEData memory _sData = _safeData[_safe]; if (_dst == _sData.owner) revert AlreadySafeOwner(); _usrSafes[_sData.owner].remove(_safe); _usrSafesPerCollat[_sData.owner][_sData.collateralType].remove(_safe); _usrSafes[_dst].add(_safe); _usrSafesPerCollat[_dst][_sData.collateralType].add(_safe); _safeData[_safe].owner = _dst; emit TransferSAFEOwnership(msg.sender, _safe, _dst); }
It modifies only _usrSafes
, _usrSafesPerCollat
and _safeData
mappings and does not modify safeCan
mapping.
After transferSAFEOwnership(123, B)
call, those mappings look like this:
_usrSafes[B] = [123] _usrSafesPerCollat[B][_sData.collateralType] = [123] _safeData[123].owner = B; safeCan[A][123][X] // notice this is from previous allowSAFE() call safeCan[A][123][Y] // notice this is from previous allowSAFE() call
Now, at some point in the future, the safe goes back to user A. Function transferSAFEOwnership()
is being called once again: transferSAFEOwnership(123, A)
.
User A
get access to the safe. However, he does not remember that he previously sets access to users X
and Y
. Those mappings:
safeCan[A][123][X] safeCan[A][123][Y]
are still there - meaning that X
and Y
has still access to that safe, even though the safe was just transferred back to A
and safe's permissions should be cleared.
Now, when user X
or Y
will try to call any function with safeAllowed
modifier - they will be allowed, since safeAllowed
will return true for them, because X
and Y
are still in safeCan
mapping.
Manual code review
Whenever transfer ownership - make sure to reset safeCan[old_owner][safe]
mapping.
Access Control
#0 - c4-pre-sort
2023-10-26T03:38:25Z
raymondfam marked the issue as low quality report
#1 - c4-pre-sort
2023-10-26T03:38:44Z
raymondfam marked the issue as duplicate of #41
#2 - c4-pre-sort
2023-10-27T04:34:40Z
raymondfam marked the issue as not a duplicate
#3 - c4-pre-sort
2023-10-27T04:34:45Z
raymondfam marked the issue as primary issue
#4 - c4-pre-sort
2023-10-27T04:34:50Z
raymondfam marked the issue as high quality report
#5 - c4-sponsor
2023-10-27T23:06:14Z
pi0neerpat (sponsor) confirmed
#6 - pi0neerpat
2023-10-27T23:06:51Z
Agreed, we would expect that permissions on a safe are cleared upon transfer. It would be unexpected for permissions to persist through such transfers, even if you received back a safe you previously owned.
#7 - c4-judge
2023-11-02T07:53:18Z
MiloTruck marked the issue as selected for report
#8 - MiloTruck
2023-11-02T07:57:35Z
The warden has demonstrated how transferSAFEOwnership()
does not clear safe permissions in safeCan
, which will cause previously "approved" addresses to still have access under the following conditions:
allowSAFE()
beforehand to remove the "approved" address.This is unintended functionality, but given that it requires multiple unlikely conditions for this to become a problem, I believe medium severity is appropriate.
#9 - c4-judge
2023-11-02T08:47:32Z
MiloTruck marked the issue as satisfactory
🌟 Selected for report: MrPotatoMagic
Also found by: 0xMosh, 0xPsuedoPandit, 0xhacksmithh, 8olidity, Al-Qa-qa, Baki, Bughunter101, Krace, Stormreckson, T1MOH, Tendency, eeshenggoh, fibonacci, hals, immeas, kutugu, lsaudit, m4k2, mrudenko, okolicodes, phoenixV110, spark, twicek, xAriextz
94.0139 USDC - $94.01
address(0)
in constructor in SAFEHandler.sol
ISAFEEngine(_safeEngine).approveSAFEModification(msg.sender);
There's no check if _safeEngine
is address(0)
.
ODProxy.sol
(_succeeded, _response) = _target.delegatecall(_data);
If there's no contract at _target
, the _target.delegatecall(_data)
will return true.
Check for the contract existence before delegate calling to the _target
.
OWNER
is immutable in ODProxy.sol
, which means it won't be possible to change it in the futureWhen, for any reason - there would be need to change the owner of ODProxy.sol
- it won't be possible, since OWNER
is set as immutable variable.
address public immutable OWNER;
ODProxy.sol
function quitSystem(uint256 _safe, address _dst) external safeAllowed(_safe) handlerAllowed(_dst) { (...) function enterSystem(address _src, uint256 _safe) external handlerAllowed(_src) safeAllowed(_safe) {
Functions quitSystem()
and enterSystem()
- are related to the similar funcionality. First one allows to quit system, while the second one - enter it.
The parameters order should be unified for them. In current implementation: for quitSystem()
_safe
is the 1st parameter, while _dst
the 2nd one.
For enterSystem()
, _safe
is the 2nd parameter, while _src
is the 1st one. Since both quitSystem()
and enterSystem()
operates on _safe
- consider using it as 1st parameter in both functions:
quitSystem(uint256 _safe, address _dst) enterSystem(uint256 _safe, address _src)
OdProxy.sol
function quitSystem(uint256 _safe, address _dst) external safeAllowed(_safe) handlerAllowed(_dst) { (...) function enterSystem(address _src, uint256 _safe) external handlerAllowed(_src) safeAllowed(_safe) {
Consider unifying the order of modifiers to be the same in functions, which share related functionality.
Firstly, use safeAllowed(_safe)
, then handlerAllowed(_dst)
:
function quitSystem(uint256 _safe, address _dst) external safeAllowed(_safe) handlerAllowed(_dst) { (...) function enterSystem(address _src, uint256 _safe) external safeAllowed(_safe) handlerAllowed(_src) {
address(0)
in constructor in ODSafeManager.sol
vault721 = IVault721(_vault721)
While constructor
verifies that _safeEngine
is not address 0, it does not verify if _vault721
is not address 0.
address(0)
in functions in ODSafeManager.sol
allowHandler()
File: ODProxy.solhandlerCan[msg.sender][_usr] = _ok;
There's no check if _usr
is not address(0)
.
ODSafeManager.sol
Functions addSAFE()
and removeSAFE()
do not emit events when safe is added/removed.
function addSAFE(uint256 _safe) external { SAFEData memory _sData = _safeData[_safe]; _usrSafes[msg.sender].add(_safe); _usrSafesPerCollat[msg.sender][_sData.collateralType].add(_safe); } /// @inheritdoc IODSafeManager function removeSAFE(uint256 _safe) external safeAllowed(_safe) { SAFEData memory _sData = _safeData[_safe]; _usrSafes[_sData.owner].remove(_safe); _usrSafesPerCollat[_sData.owner][_sData.collateralType].remove(_safe); }
ODSafeManager.sol
New safe can be added to _usrSafes
by calling addSAFE()
:
function addSAFE(uint256 _safe) external { SAFEData memory _sData = _safeData[_safe]; _usrSafes[msg.sender].add(_safe); _usrSafesPerCollat[msg.sender][_sData.collateralType].add(_safe); }
This means, that there's no limit of number of safes which can be added. User can add addSAFE()
with new safe as many times he/she wants. This means, that _usrSafes
and _usrSafesPerCollat
can contain a lot of elements.
function getSafesData(address _usr) external view returns (uint256[] memory _safes, address[] memory _safeHandlers, bytes32[] memory _cTypes) { _safes = _usrSafes[_usr].values(); _safeHandlers = new address[](_safes.length); _cTypes = new bytes32[](_safes.length); for (uint256 _i; _i < _safes.length; _i++) { _safeHandlers[_i] = _safeData[_safes[_i]].safeHandler; _cTypes[_i] = _safeData[_safes[_i]].collateralType; } }
we are looping over _usrSafes[_usr]
. This means, that if there are a lot of added safes, function will revert with out of gas error. It should be a way for getSafesData()
to display limited set of safes, to avoid that revert. E.g. re-implement a function in a way, which will allow to choose how many safes we want to return (and the starting point, e.g. we want to get limit
set starting at index offset
).
One way of solving this problem would be re-implementing getSafesData()
by adding offset
and limit
parameters to it.
function getSafesData(address _usr, uint _offset, uint _limit) external view returns (uint256[] memory _safes, address[] memory _safeHandlers, bytes32[] memory _cTypes) { _safes = _usrSafes[_usr].values(); _safeHandlers = new address[](_safes.length); _cTypes = new bytes32[](_safes.length); require(_safes.length >= _offset + _limit, "Out of bounds error!"); for (uint256 _i = _offset; _i < _limit; _i++) { _safeHandlers[_i] = _safeData[_safes[_i]].safeHandler; _cTypes[_i] = _safeData[_safes[_i]].collateralType; } }
Vault721.sol
Function which set/change important protocol's parameters should emit events. Below, there's a list of functions from Vault721.sol
which does not emit events:
updateNftRenderer
updateContractURI
setSafeManager
setNftRenderer
ODSafeManager.sol
// Give the safe ownership to a dst address. function transferSAFEOwnership(uint256 _safe, address _dst) external {
Give the safe ownership to a dst address.
should be changed to Give the safe ownership to a _dst address.
, since function transferSAFEOwnership()
has _dst
(not dst
) parameter.
UniV3Relayer.sol
more specificMethod will return invalid if the pool doesn't have enough history
There's no explanation what "invalid" means. It turns out, that when pool doesn't have enough history, function will return: (0, false)
.
Whenever commenting a function behavior, be as specific as possible. Word "invalid" is too ambiguous in this context.
AccountingEngine.sol
safeEngine.settleDebt(_rad); totalOnAuctionDebt -= _rad;
Reduce the totalOnAuctionDebt
before calling safeEngine.settleDebt()
.
This does not pose any security risk, especially, that safeEngine.settleDebt()
does not use totalOnAuctionDebt
variable. Nonetheless, coding with keeping Checks Effects Interactions pattern in mind is good security practice.
#0 - c4-pre-sort
2023-10-27T01:10:46Z
raymondfam marked the issue as sufficient quality report
#1 - c4-judge
2023-11-03T16:43:56Z
MiloTruck marked the issue as grade-a