Platform: Code4rena
Start Date: 30/06/2023
Pot Size: $100,000 USDC
Total HM: 8
Participants: 22
Period: 14 days
Judge: Trust
Total Solo HM: 6
Id: 253
League: ETH
Rank: 4/22
Findings: 3
Award: $6,244.37
π Selected for report: 2
π Solo Findings: 0
4131.8959 USDC - $4,131.90
The function combinePermissions
adds permission to availabe permissions.
If the same permission is added twice, then this will result in a new and different permission.
For example adding _PERMISSION_STATICCALL
twice results in _PERMISSION_SUPER_DELEGATECALL
.
This way accidentally dangerous permissions can be set.
Once someone has a dangerous permission, for example _PERMISSION_SUPER_DELEGATECALL
, they can change all storage parameters of the Universion Profile and then steal all the assets.
The following code shows this. Run with forge test -vv
to see the console output.
pragma solidity ^0.8.13; import "../../contracts/LSP6KeyManager/LSP6KeyManager.sol"; import "../../contracts/LSP0ERC725Account/LSP0ERC725Account.sol"; import "../../contracts/LSP2ERC725YJSONSchema/LSP2Utils.sol"; import "../../contracts/LSP6KeyManager/LSP6Constants.sol"; import "./UniversalProfileTestsHelper.sol"; contract SetDataRestrictedController is UniversalProfileTestsHelper { LSP0ERC725Account public mainUniversalProfile; LSP6KeyManager public keyManagerMainUP; address public mainUniversalProfileOwner; address public combineController; function setUp() public { mainUniversalProfileOwner = vm.addr(1); vm.label(mainUniversalProfileOwner, "mainUniversalProfileOwner"); combineController = vm.addr(10); vm.label(combineController, "combineController"); mainUniversalProfile = new LSP0ERC725Account(mainUniversalProfileOwner); // deploy LSP6KeyManagers keyManagerMainUP = new LSP6KeyManager(address(mainUniversalProfile)); transferOwnership( mainUniversalProfile, mainUniversalProfileOwner, address(keyManagerMainUP) ); } function testCombinePermissions() public { bytes32[] memory ownerPermissions = new bytes32[](3); ownerPermissions[0] = _PERMISSION_STATICCALL; ownerPermissions[1] = _PERMISSION_STATICCALL; givePermissionsToController( mainUniversalProfile, combineController, address(keyManagerMainUP), ownerPermissions ); bytes32 key = LSP2Utils.generateMappingWithGroupingKey(_LSP6KEY_ADDRESSPERMISSIONS_PERMISSIONS_PREFIX,bytes20(combineController)); bytes memory r = mainUniversalProfile.getData(key); console.logBytes(r); // 0x00..4000 SUPER_DELEGATECALL } }
See LSP6Utils.sol#L169-L177 for the code of combinePermissions
.
Foundry
The permissions shouldn't be added, but they should be OR
d.
Here is a way to solve this:
function combinePermissions(bytes32[] memory permissions) internal pure returns (bytes32) { uint256 result = 0; for (uint256 i = 0; i < permissions.length; i++) { - result += uint256(permissions[i]); + result |= uint256(permissions[i]); } return bytes32(result); }
Math
#0 - c4-pre-sort
2023-07-15T02:55:34Z
minhquanym marked the issue as primary issue
#1 - c4-pre-sort
2023-07-15T02:56:37Z
minhquanym marked the issue as high quality report
#2 - c4-sponsor
2023-07-18T13:16:21Z
CJ42 marked the issue as disagree with severity
#3 - CJ42
2023-07-18T13:19:54Z
We don't use this library in any of the standards. It is only used in the tests.
Because LSP6Utils
is a library
contract intended to be used for developer, we consider it is an issue.
However, we consider it is unlikely that a developer will add two time the same permission in the array (if they do, it is on the developer end implementing our contracts that the mistake happen), we think the severity should be lower.
#4 - trust1995
2023-08-02T08:58:39Z
Passing multiple identical permissions cannot be viewed as a mistake on the developer's side. They may rightly assume that repeating permissions are ignored and aren't expected to prefilter. The warden has not shown a clear end-to-end scenario where this would occur. Unless a reasonable likelihood can be demonstrated, the impact should be treated as a strong Medium.
#5 - c4-judge
2023-08-02T08:58:47Z
trust1995 changed the severity to 2 (Med Risk)
#6 - c4-judge
2023-08-02T08:59:03Z
trust1995 marked the issue as satisfactory
#7 - c4-judge
2023-08-02T11:59:29Z
trust1995 marked the issue as selected for report
π Selected for report: gpersoon
Also found by: DavidGiladi, MiloTruck, Rolezn, banpaleo5, catellatech, matrix_0wl, naman1778, vnavascues
547.6635 USDC - $547.66
emit
in lsp20VerifyCall()
Function lsp20VerifyCall()
has two calls to _verifyPermissions()
, however only the first one is followed by an emit
.
This might make debugging transaction more difficult. Also indexed data by protocols like TheGraph will be incomplete.
LSP6KeyManagerCore.sol#L249-L296
function lsp20VerifyCall(...) ... { ... if (msg.sender == _target) { ... _verifyPermissions(caller, msgValue, data); emit VerifiedCall(caller, msgValue, bytes4(data)); return ... } else { ... _verifyPermissions(caller, msgValue, data); // no emit return ... } }
Add an emit
after the second call to _verifyPermissions()
:
function lsp20VerifyCall(...) ... { ... if (msg.sender == _target) { ... _verifyPermissions(caller, msgValue, data); emit VerifiedCall(caller, msgValue, bytes4(data)); return ... } else { ... _verifyPermissions(caller, msgValue, data); + emit VerifiedCall(caller, msgValue, bytes4(data)); return ... } }
renounceOwnership()
can't be done via controllerThe function renounceOwnership()
or LSP0ERC725AccountCore
can be called by a controller and the permissions of the controller are then checked via _verifyCall
. This calls _verifyPermissions()
, which allows for transferOwnership()
and acceptOwnership()
, but doesn't allow for renounceOwnership()
.
This means renounceOwnership()
can only be done via the owner
of the KeyManager
and not via a controller
.
LSP0ERC725AccountCore.sol#L655-L679
function renounceOwnership() ... { ... bool verifyAfter = _verifyCall(_owner); LSP14Ownable2Step._renounceOwnership(); ... }
LSP6KeyManagerCore.sol#L455-L511
function _verifyPermissions(...) ... { ... if (...) { ... } else if ( erc725Function == ILSP14Ownable2Step.transferOwnership.selector || erc725Function == ILSP14Ownable2Step.acceptOwnership.selector ) { LSP6OwnershipModule._verifyOwnershipPermissions(from, permissions); } else { revert InvalidERC725Function(erc725Function); } }
Determine if a controller should be able to renounceOwnership
.
If so, update _verifyPermissions()
to allow this.
If not, remove the code from renounceOwnership()
that prepares for this (e.g. the call to _verifyCall()
)
renounceOwnership()
doesn't notify UniversalReceiverFunction renounceOwnership()
doesn't notify UniversalReceiver, while transferOwnership()
and acceptOwnership()
.
This is inconsistent and could mean the administration and verification of ownership isn't accurate, especially because the operation can be started via a controller
that isn't the owner
(see LSP0ERC725AccountCore.sol renounceOwnership).
Note: this situation exists in both LSP0ERC725AccountCore
and LSP14Ownable2Step
.
LSP0ERC725AccountCore.sol#L557-L697
abstract contract LSP0ERC725AccountCore is function transferOwnership(...) ... { ... pendingNewOwner.tryNotifyUniversalReceiver(_TYPEID_LSP0_OwnershipTransferStarted,""); ... } function acceptOwnership() ... { ... previousOwner.tryNotifyUniversalReceiver(_TYPEID_LSP0_OwnershipTransferred_SenderNotification,""); msg.sender.tryNotifyUniversalReceiver(_TYPEID_LSP0_OwnershipTransferred_RecipientNotification,""); } function renounceOwnership() .. { ... // no tryNotifyUniversalReceiver } }
LSP14Ownable2Step.sol#L66-L113
abstract contract LSP14Ownable2Step is ... { function transferOwnership(...) ... { ... newOwner.tryNotifyUniversalReceiver(_TYPEID_LSP14_OwnershipTransferStarted,""); ... } function acceptOwnership() public virtual { ... previousOwner.tryNotifyUniversalReceiver(_TYPEID_LSP14_OwnershipTransferred_SenderNotification,""); msg.sender.tryNotifyUniversalReceiver(_TYPEID_LSP14_OwnershipTransferred_RecipientNotification,""); } function renounceOwnership() .. { ... // no tryNotifyUniversalReceiver } }
Also send updates from renounceOwnership()
.
The function _whenSending()
sometimes returns an error string when it encounters corrupt data. Sometimes it reverts, when the corruption is detected in generateSentAssetKeys()
.
LSP1UniversalReceiverDelegateUP.sol#L201-L252
function _whenSending(...) ... { if (typeId != _TYPEID_LSP9_OwnershipTransferred_SenderNotification) { ... (dataKeys, dataValues) = LSP5Utils.generateSentAssetKeys(...); // reverts on corrupt data if (dataKeys.length == 0 && dataValues.length == 0) return "LSP1: asset data corrupted"; // returns on corrupt data } } }
LSP5ReceivedAssets/LSP5Utils.sol#L117-L137
function generateSentAssetKeys(...) ... { ... bytes memory lsp5ReceivedAssetsCountValue = getLSP5ReceivedAssetsCount(account); if (lsp5ReceivedAssetsCountValue.length != 16) { revert InvalidLSP5ReceivedAssetsArrayLength(...); } }
Consider handling all cases of data corruption in the same way.
generateSentAssetKeys()
can revertFunction generateSentAssetKeys()
can revert on uint128 newArrayLength = oldArrayLength - 1
.
This only happens when the data is already corrupt.
However in this case no useful error/revert message is given.
function generateSentAssetKeys(...) ... { ... uint128 oldArrayLength = uint128(bytes16(lsp5ReceivedAssetsCountValue)); // Updating the number of the received assets (decrementing by 1 uint128 newArrayLength = oldArrayLength - 1; // could revert (only when data is already corrupt) ... }
Consider an extra check, for example in the following way:
function generateSentAssetKeys(...) ... { ... uint128 oldArrayLength = uint128(bytes16(lsp5ReceivedAssetsCountValue)); + if (oldArrayLength == 0) revert Invalidlsp5ReceivedAssetsCountValue(); // Updating the number of the received assets (decrementing by 1 uint128 newArrayLength = oldArrayLength - 1; // could revert (only when data is already corrupt) ... }
renounceOwnership()
on risks is incompleteAfter a complete renounceOwnership()
the owner
will be 0. Not only direct calls that check the owner will fail, but also the checks via _verifyCall()
will fail.
This is because _verifyCall()
interacts with the owner
, which no longer exists.
This means that all (write/execute) actions on the Universal Profile will fail.
Read only actions and previousely set allowances will keep working.
So renounceOwnership()
will make the Universal Profile largely unuseable.
The warnings in the source do not show the entire extend of the issue, see LSP0ERC725AccountCore.sol#L642-L655.
LSP0ERC725AccountCore.sol#L222-L257
function execute(...) ... { ... address _owner = owner(); ... bool verifyAfter = LSP20CallVerification._verifyCall(_owner); bytes memory result = ERC725XCore._execute(...); ... }
LSP20CallVerification.sol#L23-L43
function _verifyCall(address logicVerifier) ... { (bool success, bytes memory returnedData) = logicVerifier.call(...); // call to address 0, will succeed, but no data returned _validateCall(false, success, returnedData); // will revert due to lack of data bytes4 magicValue = abi.decode(returnedData, (bytes4)); if (bytes3(magicValue) != bytes3(ILSP20.lsp20VerifyCall.selector)) // otherwise it will revert here revert LSP20InvalidMagicValue(false, returnedData); ... }
Consider enhancing the comments to include the risks:
@custom:danger Leaves the contract without an owner. Once ownership of the contract has been renounced, any functions that are restricted to be called by the owner +or a controller will be permanently inaccessible, making these functions not callable anymore and unusable.
_verifyCall()
doesn't check for EOAsThe function _verifyCall()
calls a logicVerifier
without checking if this is a contract or an EOA.
Whereas function isValidSignature()
first checks if the owner
is an EOA.
The risk is limited because the checks in _verifyCall()
will revert.
LSP0ERC725AccountCore.sol#L222-L257
function _verifyCall(address logicVerifier) ... { (bool success, bytes memory returnedData) = logicVerifier.call(...); // call EOA will succeed, but no data returned _validateCall(false, success, returnedData); // will revert due to lack of data bytes4 magicValue = abi.decode(returnedData, (bytes4)); if (bytes3(magicValue) != bytes3(ILSP20.lsp20VerifyCall.selector)) // otherwise it will revert here revert LSP20InvalidMagicValue(false, returnedData); ... }
LSP0ERC725AccountCore.sol#L734-L774
function isValidSignature(...) ... { address _owner = owner(); // If owner is a contract if (_owner.code.length > 0) { (bool success, bytes memory result) = _owner.staticcall( ... ); } // If owner is an EOA else { ... } }
Consider checking it the logicVerifier
is an EOA in function _verifyCall()
.
getPermissionName()
The function getPermissionName()
retrieves the string equivalent of permissions. This is used for error messages.
However some permissions are missing.
function getPermissionName(bytes32 permission) internal pure returns (string memory errorMessage) { if (permission == _PERMISSION_CHANGEOWNER) return "TRANSFEROWNERSHIP"; if (permission == _PERMISSION_EDITPERMISSIONS) return "EDITPERMISSIONS"; if (permission == _PERMISSION_ADDCONTROLLER) return "ADDCONTROLLER"; if (permission == _PERMISSION_ADDEXTENSIONS) return "ADDEXTENSIONS"; if (permission == _PERMISSION_CHANGEEXTENSIONS) return "CHANGEEXTENSIONS"; if (permission == _PERMISSION_ADDUNIVERSALRECEIVERDELEGATE) return "ADDUNIVERSALRECEIVERDELEGATE"; if (permission == _PERMISSION_CHANGEUNIVERSALRECEIVERDELEGATE) return "CHANGEUNIVERSALRECEIVERDELEGATE"; if (permission == _PERMISSION_REENTRANCY) return "REENTRANCY"; if (permission == _PERMISSION_SETDATA) return "SETDATA"; if (permission == _PERMISSION_CALL) return "CALL"; if (permission == _PERMISSION_STATICCALL) return "STATICCALL"; if (permission == _PERMISSION_DELEGATECALL) return "DELEGATECALL"; if (permission == _PERMISSION_DEPLOY) return "DEPLOY"; if (permission == _PERMISSION_TRANSFERVALUE) return "TRANSFERVALUE"; if (permission == _PERMISSION_SIGN) return "SIGN"; }
Add the missing permissions:
function getPermissionNameComplete(bytes32 permission) internal pure returns (string memory errorMessage) { ... + if (permission == _PERMISSION_SUPER_TRANSFERVALUE) return "SUPER_TRANSFERVALUE"; + if (permission == _PERMISSION_SUPER_TRANSFERVALUE) return "SUPER_TRANSFERVALUE"; + if (permission == _PERMISSION_SUPER_CALL) return "SUPER_CALL"; + if (permission == _PERMISSION_SUPER_STATICCALL) return "SUPER_STATICCALL"; + if (permission == _PERMISSION_SUPER_DELEGATECALL) return "SUPER_DELEGATECALL"; + if (permission == _PERMISSION_SUPER_SETDATA) return "SUPER_SETDATA"; + if (permission == _PERMISSION_ENCRYPT) return "ENCRYPT"; + if (permission == _PERMISSION_DECRYPT) return "DECRYPT"; }
setDataBatch()
of LSP0ERC725AccountCore
Function setDataBatch()
of ERC725YCore has an extra check compared to the version in LSP0ERC725AccountCore
.
The extra check verifies if dataKeys.length == 0
and then reverts.
The risk of this is very low though, however it is not consistent.
Note: several other Batch functions don't check for array lenghts of 0 either.
function setDataBatch(bytes32[] memory dataKeys, ...) ... { ... if (dataKeys.length == 0) { revert ERC725Y_DataKeysValuesEmptyArray(); } }
LSP0ERC725AccountCore.sol#L380-L424
function setDataBatch(bytes32[] memory dataKeys, ...) ... { // no check on dataKeys.length == 0 }
Consider making the code consistent.
_pendingOwner
can be reset via _renounceOwnership()
Assume transferOwnership()
has been called and the _pendingOwner
has been set.
The function _renounceOwnership()
can now be used to reset the _pendingOwner
, which means that the next acceptOwnership()
will fail.
Note: After the timeout periond of _renounceOwnership()
, the original situation is restored, so _renounceOwnership()
has to be done twice to renounce ownership.
Note: Calling transferOwnership()
again will also update the _pendingOwner
.
This might an unexpected situation.
abstract contract LSP14Ownable2Step is ... { function _renounceOwnership() ... { ... if (currentBlock > confirmationPeriodEnd || _renounceOwnershipStartedAt == 0 ) { _renounceOwnershipStartedAt = currentBlock; delete _pendingOwner; ... return; } ... } }
Doublecheck if this is the intended behaviour.
Consider to notify the UniversalReceiver
of the fact that OwnershipTransferStarted
is no longer relevant.
#0 - c4-pre-sort
2023-07-17T22:48:15Z
minhquanym marked the issue as high quality report
#1 - CJ42
2023-07-28T08:23:02Z
Ok we confirm. Report is of great quality. However, we might have to go through the points individually. Some of them can be implemented and fixed, other might have to be discussed if we make the changes or not (e.g: nb 8)
#2 - c4-sponsor
2023-07-28T08:23:08Z
CJ42 marked the issue as sponsor confirmed
#3 - c4-judge
2023-08-02T09:00:21Z
trust1995 marked the issue as grade-a
#4 - c4-judge
2023-08-02T12:04:08Z
trust1995 marked the issue as selected for report
#5 - trust1995
2023-08-03T07:07:37Z
1 - NC 2 - NC 3 - L 4 - L+ 5 - NC 6 - NC 7 - L 8 - L 9 - L 10 - L+
π Selected for report: K42
Also found by: catellatech, gpersoon
1564.8148 USDC - $1,564.81
I've made this drawing to be able to understand the code: https://twitter.com/gpersoon/status/1676588871255990272 I've shared it on twitter and discord to help fellow wardens to also understand the Lukso protocol
40 hours
#0 - c4-pre-sort
2023-07-17T22:56:02Z
minhquanym marked the issue as high quality report
#1 - c4-sponsor
2023-07-28T07:49:48Z
CJ42 marked the issue as sponsor confirmed
#2 - c4-judge
2023-08-02T09:00:37Z
trust1995 marked the issue as grade-a
#3 - c4-judge
2023-08-02T09:00:43Z
trust1995 marked the issue as satisfactory