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: 58/77
Findings: 2
Award: $30.30
🌟 Selected for report: 0
🚀 Solo Findings: 0
21.9995 USDC - $22.00
https://github.com/open-dollar/od-contracts/blob/dev/src/contracts/proxies/ODSafeManager.sol#L49-L53 https://github.com/open-dollar/od-contracts/blob/dev/src/contracts/proxies/ODSafeManager.sol#L105-L109 https://github.com/open-dollar/od-contracts/blob/67e5917e7dc0c16324aff3fde0298cd218a15152/src/contracts/proxies/ODSafeManager.sol#L155-L232 https://github.com/open-dollar/od-contracts/blob/67e5917e7dc0c16324aff3fde0298cd218a15152/src/contracts/proxies/ODSafeManager.sol#L241-L252
safeAllowed()
in ODSafeManager
doesn't work as intended and will always revert making the user unable to interact with its own safeThe modifier safeAllowed()
in ODSafeManager
is used as an Access Control mean.
Given a Safe ID, it verifies one of the two following conditions :
msg.sender
is the owner of the safemsg.sender
is allowed to interact with this safe (using the safeCan
state variable)In the protocol, the owner of a safe is supposed to be an ODProxy
(owned by a user) and users interact with the protocol through their own ODProxy
.
This means that every ODSafeManager
functions protected by the safeAllowed()
modifier can be executed by the allowed ODProxy
(unless another address has been allowed to interact with it with safeCan(address,uint256,address)
BUT this has to be done using the allowSAFE()
function which is also protected by the safeAllowed()
modifier, making it unreachable).
Functions unreachable due to this bug : allowSAFE()
, modifySAFECollateralization()
, transferCollateral()
, transferInternalCoins()
, enterSystem()
, quitSystem()
, moveSAFE()
, removeSAFE()
, protectSAFE()
.
https://github.com/open-dollar/od-contracts/blob/dev/src/contracts/proxies/ODSafeManager.sol#L49-L53
Taking example on the allowSAFE
function :
modifier safeAllowed(uint256 _safe) { address _owner = _safeData[_safe].owner; if (msg.sender != _owner && safeCan[_owner][_safe][msg.sender] == 0) revert SafeNotAllowed(); _; } ... 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); }
The issue here is that the ODProxy
interacts with other contracts (such as ODSafeManager
) using delegatecall
https://github.com/open-dollar/od-contracts/blob/dev/src/contracts/proxies/ODProxy.sol#L26-L35
function execute(address _target, bytes memory _data) external payable onlyOwner returns (bytes memory _response) { if (_target == address(0)) revert TargetAddressRequired(); bool _succeeded; (_succeeded, _response) = _target.delegatecall(_data); if (!_succeeded) { revert TargetCallFailed(_response); } }
A delegatecall
keeps the msg.sender
value of the initial caller (the initial EOA address that tries to interact with the protocol) thus, the modifier will always revert
as the safe owner
will always be different than the msg.sender
This file should be located in test/BrokenModifier.t.sol
and can be executed with
forge test --match-contract BrokenModifier --match-test testBrokenModifier -vvv
// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import {Test, console} from "forge-std/Test.sol"; import {Vault721} from "src/contracts/proxies/Vault721.sol"; import {ODProxy} from '@contracts/proxies/ODProxy.sol'; import {BasicActions} from '@contracts/proxies/actions/BasicActions.sol'; import {ODSafeManager} from '@contracts/proxies/ODSafeManager.sol'; import {SAFEEngine} from '@contracts/SAFEEngine.sol'; import '@interfaces/ISAFEEngine.sol'; contract BrokenModifier is Test { address public deployer = address(1); address public friend = address(2); address public governor = address(3); address public legitUser = address(4); Vault721 public vault721; ODSafeManager public safeManager; SAFEEngine public safeEngine; BasicActions public basicActions; ISAFEEngine.SAFEEngineParams public safeEngineParameters = ISAFEEngine.SAFEEngineParams( 10 ** 18, // WAD 10 ** 45 // RAD ); function setUp() public { vm.startPrank(deployer); vault721 = new Vault721(governor); safeEngine = new SAFEEngine(safeEngineParameters); safeManager = new ODSafeManager(address(safeEngine), address(vault721)); basicActions = new BasicActions(); vm.stopPrank(); } function testBrokenModifier() public { // first we build a proxy for a user vm.startPrank(legitUser); vault721.build(legitUser); ODProxy userProxy = ODProxy(vault721.getProxy(legitUser)); bytes memory payload = abi.encodeWithSelector( basicActions.openSAFE.selector, address(safeManager), "", address(userProxy) ); bytes memory safeData = userProxy.execute( address(basicActions), payload ); uint256 userSafeId = abi.decode(safeData, (uint256)); // userProxy owns the SAFE : the state of the ODSafeManager has been updated assertEq( safeManager.safeData(userSafeId).owner, address(userProxy) ); // here we try to allow the address of our "friend" by setting the value 1 in the // `safeCan` mapping on ODSafeManager payload = abi.encodeWithSelector( safeManager.allowSAFE.selector, userSafeId, friend, 1 ); // this will revert in the modifier because msg.sender and _owner are always different // because of the delegatecall userProxy.execute( address(safeManager), payload ); } }
In the output of the command, we can clearly see that the transaction revert with SafeNotAllowed()
in the first place
... ├─ [970] ODSafeManager::safeData(1) [staticcall] │ └─ ← (0x582e4a5B8F0c154922e086061cC6Dd07F056EAC1, 0x4794a3F9d446e0D658ceaa048a26d58aa9A9725E, 0x0000000000000000000000000000000000000000000000000000000000000060) ├─ [6698] ODProxy::execute(ODSafeManager: [0xc051134F56d56160E8c8ed9bB3c439c78AB27cCc], 0xe0decbcd000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001) │ ├─ [5041] ODSafeManager::allowSAFE(1, 0x0000000000000000000000000000000000000002, 1) [delegatecall] │ │ └─ ← "SafeNotAllowed()" │ └─ ← "TargetCallFailed(0xdba06d65)" └─ ← "TargetCallFailed(0xdba06d65)" Test result: FAILED. 0 passed; 1 failed; finished in 3.91ms Failing tests: Encountered 1 failing test in test/exploits/BrokenModifier.t.sol:BrokenModifier [FAIL. Reason: TargetCallFailed(0xdba06d65)] testBrokenModifier() (gas: 748309) Encountered a total of 1 failing tests, 0 tests succeeded
Foundry
We recommend one of the following mitigation steps depending on how the protocol should behave :
ODProxy
to call
(and not delegatecall
) on the target contractAccess Control
#0 - c4-pre-sort
2023-10-26T05:47:32Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-10-26T05:47:45Z
raymondfam marked the issue as duplicate of #76
#2 - c4-pre-sort
2023-10-26T19:04:39Z
raymondfam marked the issue as duplicate of #380
#3 - c4-judge
2023-11-02T18:14:36Z
MiloTruck marked the issue as not a duplicate
#4 - c4-judge
2023-11-02T18:15:00Z
MiloTruck marked the issue as duplicate of #294
#5 - c4-judge
2023-11-08T00:21:46Z
MiloTruck changed the severity to 2 (Med Risk)
#6 - MiloTruck
2023-11-08T00:26:32Z
This report assumes that ODProxy
delegate call directly into the ODSafeManager
contract, and doesn't highlight the key issue which is that BasicActions.sol
has missing functions.
#7 - c4-judge
2023-11-08T00:26:33Z
MiloTruck marked the issue as unsatisfactory: Insufficient proof
#8 - c4-judge
2023-11-11T08:18:51Z
MiloTruck removed the grade
#9 - c4-judge
2023-11-11T08:19:12Z
MiloTruck marked the issue as satisfactory
#10 - MiloTruck
2023-11-11T08:20:20Z
🌟 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
8.3007 USDC - $8.30
addSAFE(uint256 _safe)
in ODSafeManager
letting anyone call this function with arbitrary valueLOW as it doesn't really impact the protocol
// @audit arbitrary call to add a safe to yourself ? 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); }
addSAFE(uint256 _safe)
doesn't have the safeAllowed(_safe)
modifier as removeSAFE(uint256 _safe)
have.
In this case Anyone can add an arbitrary safe to his _usrSafes[]
list.
Add the modifier safeAllowed(_safe)
to the addSAFE(uint256 _safe)
.
#0 - c4-pre-sort
2023-10-27T00:42:58Z
raymondfam marked the issue as sufficient quality report
#1 - c4-judge
2023-11-03T16:41:07Z
MiloTruck marked the issue as grade-b