Platform: Code4rena
Start Date: 02/08/2022
Pot Size: $50,000 USDC
Total HM: 12
Participants: 69
Period: 5 days
Judge: gzeon
Total Solo HM: 5
Id: 150
League: ETH
Rank: 15/69
Findings: 2
Award: $840.41
π Selected for report: 1
π Solo Findings: 0
π Selected for report: horsefacts
Also found by: ayeslick, cccz, peritoflores, teddav, vlad_bochok
224.5009 USDC - $224.50
https://github.com/code-423n4/2022-08-mimo/blob/eb1a5016b69f72bc1e4fd3600a65e908bd228f13/contracts/proxy/MIMOProxy.sol#L29-L33 https://github.com/code-423n4/2022-08-mimo/blob/eb1a5016b69f72bc1e4fd3600a65e908bd228f13/contracts/proxy/MIMOProxy.sol#L4
PBR proxy owner change protection can bypassed / DoS
PRBProxy has a protection to prevent malicious delegatecall to overwrite owner.
function execute(address target, bytes calldata data) public payable override returns (bytes memory response) { ... ... // Check that the owner has not been changed. if (owner_ != owner) { @audit owner change protection revert CustomErrors.OWNER_CHANGED(owner_, owner); } ... }
This is an important protection because delegatecall
has the option to set any variable in the contract including the owner.
However, you have made some changes to PRB Proxy adding more storage variables (by inheriting Initializable
) that are vulnerable to be changed via a malicious call.
abstract contract Initializable { /** * @dev Indicates that the contract has been initialized. * @custom:oz-retyped-from bool */ uint8 private _initialized; /;
that means that _initialized
is part of the storage of MIMO proxy.
So the malicious contract called by execute can
_initalized
and set to zero in the delegatecall
(owner still is the same so execute will finish correctly).initialize()
and set a new owner.I believe that it is better to use as little storage variables as possible. Ideally ZERO.
The solution I have been thinking is
Remove Initializable
contract
Remove initialize
function
Create a constructor to set owner and mark it as immutable
.
Set minGasReserve as constant (I see no point in this variable, worse that is storage, and I believe that here the vulnerability is from PRB proxy). minGasReserve can be used to DoS if set too high because of an underflow ( uint256 stipend = gasleft() - minGasReserve; )
Maybe you want to come with a different solution. For example, check that at the end of execute
the contract is still initialized.
However, I have been thinking how to fix this and my proposal solves another bug related to multicall
that I will be reporting next.
#0 - horsefacts
2022-08-08T22:20:44Z
#1 - RnkSngh
2022-08-10T10:29:44Z
We're picking #161 as the original since that issue was well written, provided an example, and had the correct risk assessment
π Selected for report: peritoflores
Also found by: 8olidity, vlad_bochok
615.9148 USDC - $615.91
msg.value
in a loop can be used to drain proxy funds
While BoringBatchable
is out of the scope, this bug affects seriously MIMOProxy
as it inherits.
Some time ago I read a report about an auditor called samczsung (https://samczsun.com/two-rights-might-make-a-wrong/). I believe that you are having the same problem here.
I will try to explain it as brief as possible but I can add a PoC in QA stage if required.
This vulnerability comes from the fact that msg.value
and msg.sender
are persisted in delegatecall
.
It is possible to call execute()
(which is payable ) from batch()
(which is also payable ) because both are public functions. (For now ignore the fact that execute()
has access control).
The attacker would call batch()
sending, for example, 1 ETH with an array of 100 equal items that call execute()
This execute()
will call and external contract 100 times and in every time it will send 1ETH from proxy funds (not from the attacker).
If the receiving contract stores these value then the proxy wallet will be drained.
While this is already a high risk and there should be many attacking scenarios I would like to show you a pretty simple one.
Suppose the owner would like to grant access to a target with a normal function (maybe no even payable
).
For example suppose that the owner grant access to the function
function goodFunction() public
This function has the selector 0x0d092393
. However, for some reason. the owner mistyped the selector and grant access to non existing function 0x0d09392
.
Then if the target contract has the so common function.
fallback() external payable { }
Then the attacker can drain wallet funds using this selector as I explained above.
The solution is pretty straightforward.
Remove payable
from batch()
in BoringBatchable
.
#0 - horsefacts
2022-08-08T22:34:05Z
Agree this is possible. I would note that there is a big warning at the top of BoringBatchable
that links this very blog post.
#1 - RayXpub
2022-08-10T10:32:21Z
We are not modifying any state in the MimoProxy
based on msg.value, so this doesn't apply here. Please refer to our test case here
#2 - peritoflores
2022-08-11T14:56:46Z
Hi @RayXpub . I found that there is an error in the test case that you mentioned. The test is passing because the contract has no ETH and you call batch with false parameter.
The second delegatecall is reverting. However, by design delegatecall will not revert the main transaction and instead will return false that is ignored in this case.
To show you this I have created a PoC with a few modification to your original test. I just send ETH before an then compared that the amount deposited was double.
it("PoC: should be able to reuse msg.value for multiple deposits", async () => { const { mimoProxy, vaultActions, vaultsDataProvider, wmatic } = await setup(); //Send ETH to the proxy RECEIVE EXTERNAL PAYABLE const [owner] = await ethers.getSigners(); owner.sendTransaction({ to: mimoProxy.address, value: DEPOSIT_AMOUNT }); await mimoProxy.execute(vaultActions.address, vaultActions.interface.encodeFunctionData("depositETH"), { value: DEPOSIT_AMOUNT, }); const vaultIdBefore = await vaultsDataProvider.vaultId(wmatic.address, mimoProxy.address); const vaultBalanceBefore = await vaultsDataProvider.vaultCollateralBalance(vaultIdBefore); const data = vaultActions.interface.encodeFunctionData("depositETH"); mimoProxy.batch( [ mimoProxy.interface.encodeFunctionData("execute", [vaultActions.address, data]), mimoProxy.interface.encodeFunctionData("execute", [vaultActions.address, data]), ], true, { value: DEPOSIT_AMOUNT }, ); const vaultId = await vaultsDataProvider.vaultId(wmatic.address, mimoProxy.address); const vaultBalanceAfter = await vaultsDataProvider.vaultCollateralBalance(vaultId); expect(vaultBalanceAfter).to.be.equal(vaultBalanceBefore.add(DEPOSIT_AMOUNT).add(DEPOSIT_AMOUNT));} );
#3 - RayXpub
2022-08-12T13:41:31Z
Hi @peritoflores ,
Thanks for providing a PoC. It seems we misunderstood the issue as we were looking at it in the context of the miso platform vulnerability described in the paradigm article where it is our understanding that the issue was a msg.value
reliant state update. Here ETH are actually transferred in each call. However, for an attacker to be able to call execute()
he would need to have been granted permission, so that would rely an approval made by the MIMOProxy
owner.
In the case of the fallback function this would require the owner making a mistake while granting permission by entering an erroneous selector and the target contract would need to have a fallback.
As we do not see any scenario where this issue would work without a user mistake we consider that this should be labeled as medium risk. But we are considering making all the MIMOProxy
functions non payable, this is still being discussed.
Btw the PoC provided is missing an await
on the owner.sendTransaction
line which ends up not really showcasing the issue but we did manage to reproduce the scenario.
#4 - gzeoneth
2022-08-21T14:46:50Z
Agree this is not High Risk due to the requirement of owner privilege.