MultiSig Timelock

First Flight #55
Beginner FriendlyWallet
100 EXP
Submission Details
Impact: low
Likelihood: low

Permission Model Inconsistency: Documentation Claims Use of `DEFAULT_ADMIN_ROLE`, but Actual Access Control Relies Entirely on `Ownable`

Author Revealed upon completion

Permission Model Inconsistency: Documentation Claims Use of DEFAULT_ADMIN_ROLE, but Actual Access Control Relies Entirely on Ownable

Description

The contract documentation states that the deployer receives both the DEFAULT_ADMIN_ROLE in AccessControl and the custom SIGNING_ROLE, implying that administrative permissions are managed by the Role-Based Access Control (RBAC) mechanism.
However, all administrative functions (e.g., grantSigningRole) actually perform permission checks only via the onlyOwner modifier provided by Ownable, and there is no logic in the contract that checks the DEFAULT_ADMIN_ROLE. The inheritance of AccessControl is not practically used in governance, leading to misleading documentation and potential misunderstandings of the real permission model.

// MultiSigTimelock.sol
contract MultiSigTimelock is Ownable, AccessControl, ReentrancyGuard {
constructor() Ownable(msg.sender) {
// ...Original code
_grantRole(SIGNING_ROLE, msg.sender);
// @> The AccessControl constructor implicitly grants the DEFAULT_ADMIN_ROLE,
// @> but this role is never used in any permission checks throughout the contract.
}
function grantSigningRole(address _account) external nonReentrant onlyOwner noneZeroAddress(_account) {
// @> Permissions are controlled by onlyOwner (from Ownable),
// @> not by AccessControl mechanisms like hasRole(DEFAULT_ADMIN_ROLE, ...).
if (s_isSigner[_account]) {
revert MultiSigTimelock__AccountIsAlreadyASigner();
}
if (s_signerCount >= MAX_SIGNER_COUNT) {
revert MultiSigTimelock__MaximumSignersReached();
}
s_signers[s_signerCount] = _account;
s_isSigner[_account] = true;
s_signerCount += 1;
_grantRole(SIGNING_ROLE, _account);
}
}

Risk

Likelihood:

  • Developers or integrators reading the documentation will mistakenly believe that administrative permissions are controlled by AccessControl, especially since the documentation explicitly mentions the DEFAULT_ADMIN_ROLE.

  • Future maintainers may attempt to add new features based on AccessControl when extending the contract, but since actual permissions are controlled by Ownable, this can easily introduce permission logic conflicts or unexpected bypasses.

Impact:

  • Misunderstanding of the real ownership and permission mechanisms may lead to incorrect judgments during audits, upgrades, or third-party integrations.

  • The introduction of an unused AccessControl mechanism adds unnecessary code complexity and cognitive burden without providing any security or functional benefits.

Proof of Concept

  • Add the test_Without_DEFAULT_ADMIN_ROLE function to test/unit/MultiSigTimelockTest.t.sol as follows:

function test_Without_DEFAULT_ADMIN_ROLE() public {
assertEq(multiSigTimelock.getSignerCount(), 1);
assertTrue(multiSigTimelock.hasRole(multiSigTimelock.getSigningRole(), address(this)));
assertFalse(multiSigTimelock.hasRole(multiSigTimelock.DEFAULT_ADMIN_ROLE(), address(this)));
}
  • Run in the console: forge test --mt test_Without_DEFAULT_ADMIN_ROLE -vv

Ran 1 test for test/unit/MultiSigTimelockTest.t.sol:MultiSigTimeLockTest
[PASS] test_Without_DEFAULT_ADMIN_ROLE() (gas: 22679)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.72ms (144.70µs CPU time)

Recommended Mitigation

  • Standardize on using AccessControl and remove Ownable from the contract inheritance.

  • Replace the onlyOwner modifier with hasRole(DEFAULT_ADMIN_ROLE, msg.sender) for all administrative function permission checks.

  • Ensure full consistency between documentation and implementation (update documentation to reflect the actual permission model if retaining Ownable).

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!