At a glance
- The bug
- A function that changes something important has no check on who is allowed to call it, or the wrong check
- The wrong check
- Using
tx.originto decide who the caller is; it names the human who signed, not the contract calling you - The silent one
- An
initializefunction left callable by anyone, so the first person to find it becomes the owner - The fix
- A role check on every state-changing entry point:
onlyOwner, a role modifier, or an explicit require onmsg.sender - The habit
- List every function that mutates state or moves value, and prove to yourself each one asks "who is calling?"
The most boring bug that keeps winning
Reentrancy is famous, but the vulnerability that empties more contracts is duller: a privileged function with no guard on who may call it. Minting tokens, withdrawing the treasury, upgrading the logic, pausing the system, changing the owner. Each of these is one function, and if that function forgets to ask who is calling, everyone is allowed. Auditors find this constantly because it hides in plain sight: the code works perfectly for the developer testing it, because the developer is the owner. It only misbehaves for the stranger who calls it directly.
The missing modifier
Here is a token whose author meant only the owner to be able to mint, and forgot to say so.
contract Token {
address public owner;
mapping(address => uint256) public balanceOf;
constructor() { owner = msg.sender; }
// Meant to be owner-only. Anyone can call it.
function mint(address to, uint256 amount) external {
balanceOf[to] += amount;
}
}
Nothing in mint mentions owner. The variable exists, the intent is obvious from its name, and the function still lets any address mint itself an unlimited supply. The fix is a single check, applied as a modifier so it reads once and cannot be forgotten twice.
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
function mint(address to, uint256 amount) external onlyOwner {
balanceOf[to] += amount;
}
Use a maintained library rather than rolling your own: OpenZeppelin's Ownable gives you onlyOwner, safe ownership transfer, and a two-step handover so a typo in the new owner's address does not lock you out forever. For anything with more than one privileged role, AccessControl lets you grant and revoke named roles instead of a single all-powerful owner.
tx.origin: the check that looks right and is not
A tempting way to write "only I can call this" is to compare against tx.origin, the address that started the transaction. It is wrong, and the reason is worth understanding because it is the same reason phishing works. tx.origin is the human at the top of the call chain; msg.sender is whoever called you directly. If your wallet is a contract, or if you can be tricked into calling a malicious contract, that contract calls your protected function and tx.origin is still you.
// A wallet that trusts tx.origin
function transfer(address to, uint256 amount) external {
require(tx.origin == owner, "not owner"); // WRONG
_send(to, amount);
}
The attack: the owner is persuaded to call some unrelated-looking contract. That contract, running as an ordinary msg.sender, calls transfer on the wallet. tx.origin is the owner, because the owner did start the transaction, so the check passes and the attacker's contract drains the wallet to itself.
The rule is simple: authenticate with msg.sender, never tx.origin. The only legitimate uses of tx.origin are niche and defensive, and "is this my owner" is not one of them.
The uninitialized owner
Upgradeable contracts cannot use a constructor to set the owner, because the constructor runs on the logic contract, not the proxy that holds the state. So they use an initialize function instead. The danger is that initialize is a normal function, and if it is left callable more than once, or callable by anyone before the deployer gets to it, whoever calls it first becomes the owner.
bool private initialized;
function initialize(address _owner) external {
require(!initialized, "already initialized"); // can only run once
initialized = true;
owner = _owner;
}
Use OpenZeppelin's Initializable and its initializer modifier rather than a hand-rolled flag, and deploy the proxy and call initialize in the same transaction so there is never a moment when the contract is live but unowned. More than one protocol has lost control of a proxy to a bot that watched for a fresh, uninitialized deployment and claimed it.
Ownership is a target, not a decoration. Every privileged function is a single point of failure equal to the private key that controls the owner. For anything holding real value, put that key behind a multisig or a timelock, so one compromised laptop cannot mint the supply or upgrade the logic to something that steals it. Access control on-chain is only as strong as the key off-chain.
The short version. Make a list of every function that changes state or moves value, and check that each one asks who is calling. Do the asking with msg.sender and a maintained access-control library, never tx.origin. Guard your initializer so the contract cannot be claimed out from under you, and put the privileged key behind a multisig. The bug is boring, which is exactly why it keeps working.
References & further reading
- OpenZeppelin, Access Control.
Ownable,AccessControl, and the reasoning behind roles. - Solidity documentation, tx.origin. Why the language authors warn against it for authorization.
- OpenZeppelin, Initializable. The safe way to set state on an upgradeable contract.
- Ethereum community, Smart contract security. The broader checklist that access control sits inside.