# Satellite chains can't execute onlyGovernance functions

Bug Deep Dive #4 · 30 November 2025

Bug Deep Dives · [The Contest Academy](https://0xsimao.com/the-contest-academy) · 0xSimao

---

| Title | Satellite chains can't execute onlyGovernance functions |
| Reward | $5668, 3 dups |
| Contest | Summer.fi - 29 Sep 2025 on Sherlock |
| Author | EgisSecurity |
| Context | Wrong Call Path |

This issue is hard to explain on its own, so here is the background. The first thing to go over is the OpenZeppelin [GovernorTimelockControl.sol](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/governance/extensions/GovernorTimelockControl.sol#L10-L24), and how it interacts with the Timelock. As can be seen below, the governor contract calls the timelock, which then adds the necessary delay.

```solidity
/**
 * @dev Overridden version of the {Governor-_executeOperations} function that runs the already queued proposal
 * through the timelock.
 */
function _executeOperations(
    uint256 proposalId,
    address[] memory targets,
    uint256[] memory values,
    bytes[] memory calldatas,
    bytes32 descriptionHash
) internal virtual override {
    // execute
    _timelock.executeBatch{value: msg.value}(targets, values, calldatas, 0, _timelockSalt(descriptionHash));
    // cleanup for refund
    delete _timelockIds[proposalId];
}
```

In the Summer.fi contest, cross-chain Satellites (Governors on other chains) run their schedule and execute steps directly through the Timelock, rather than through their Governor flow.

```solidity
function _queueCrossChainProposal(
    uint256 proposalId,
    address[] memory targets,
    uint256[] memory values,
    bytes[] memory calldatas,
    bytes32 descriptionHash
) internal onlySatelliteChain returns (uint256) {
    // Satellite-only queueing of received operations into the local timelock controller
    uint48 eta = _queueOperations(
        proposalId,
        targets,
        values,
        calldatas,
        descriptionHash
    );

    emit ProposalQueued(proposalId, uint256(eta));

    return proposalId;
}
```

The problem with this approach is that when the Timelock executes any onlyGovernance function on the Satellite, it is executed directly by calling it from the Satellite::_queueCrossChainProposal() function, instead of using its inherited Governor::execute() function.

This matters because the `onlyGovernance` modifier on the Satellite requires the call to have been placed in a queue (see the OpenZeppelin contract).

```solidity
modifier onlyGovernance() {
    _checkGovernance();
    _;
}

function _checkGovernance() internal virtual {
    if (_executor() != _msgSender()) {
        revert GovernorOnlyExecutor(_msgSender());
    }
    if (_executor() != address(this)) {
        bytes32 msgDataHash = keccak256(_msgData());
        // loop until popping the expected operation - throw if deque is empty (operation not authorized)
        while (_governanceCall.popFront() != msgDataHash) {}
    }
}
```

But this only happens when execution is routed through the Governor::execute() function (which, again, calls the Timelock, see OZ), not directly to the Timelock.

```solidity
function execute(
    address[] memory targets,
    uint256[] memory values,
    bytes[] memory calldatas,
    bytes32 descriptionHash
) public payable virtual returns (uint256) {
    uint256 proposalId = hashProposal(targets, values, calldatas, descriptionHash);

    _validateStateBitmap(
        proposalId,
        _encodeStateBitmap(ProposalState.Succeeded) | _encodeStateBitmap(ProposalState.Queued)
    );

    // mark as executed before calls to avoid reentrancy
    _proposals[proposalId].executed = true;

    // before execute: register governance call in queue.
    if (_executor() != address(this)) {
        for (uint256 i = 0; i < targets.length; ++i) {
            if (targets[i] == address(this)) {
                _governanceCall.pushBack(keccak256(calldatas[i]));
            }
        }
    }

    _executeOperations(proposalId, targets, values, calldatas, descriptionHash);

    // after execute: cleanup governance call queue.
    if (_executor() != address(this) && !_governanceCall.empty()) {
        _governanceCall.clear();
    }

    emit ProposalExecuted(proposalId);

    return proposalId;
}
```

As a result, certain functions on the Satellite governor contract cannot be executed from a cross-chain call.

**Conclusion**

This finding would earn you **\$5668**. It is not especially difficult, but it needs familiarity with OpenZeppelin's governance contracts, or at least running some tests. It lands as medium severity because functionality is broken.

[**Full Report**](https://audits.sherlock.xyz/contests/1176/report)

---

Newer: [Foundation recall will inflate governance power for earnest voters](https://0xsimao.com/the-contest-academy/bug-deep-dive-5) · Older: [Lack of Emergency Market Invalidation Mechanism](https://0xsimao.com/the-contest-academy/bug-deep-dive-3)
