OpenZeppelin
Skip to content

Bypassing Smart Contract Timelocks

In this article we present a game theoretical attack against timelock incentive mechanisms.

Introduction

It is common for designers of smart contract systems to attempt to prevent stakeholders from divesting their interest in a project for some amount of time.

For example, after an ICO, the project owners may want to prevent the initial investors from dumping their newly-minted tokens on the open market for, say, 1 year. This keeps the investors’ incentives aligned with that of the project, at least until the year has passed. It may also put upward pressure on the market price of the tokens by limiting the circulating supply.

Here is another common example: A company may reward their founders with some tokens but require that the tokens vest over a 4 year period in order to keep the founder’s incentives aligned with that of the company.

In the Ethereum space, the most common way to achieve this goal of delaying the divesting of interest is to use a smart contract that implements a timelock. That is, they put the tokens into a contract with a beneficiary and a releaseTime and arrange matters so that only the beneficiary can receive the tokens and only after the releaseTime has passed.

In this article, we look at a game theoretical attack against smart contract timelock systems that allows the beneficiary (i.e., the early ICO investor or the company founder) to trustlessly sell their timelocked tokens without waiting for the timelock to expire.

The scope of the problem

It is important to note that the two attacks we’ll cover are not an exploitation of a vulnerability in the code of any smart contract timelock system (though modifications of the code can almost entirely prevent this attack, as we’ll discuss later). The attack does not require that the locked ETH or ERC20 tokens can somehow be moved before the timelock expires. Indeed, that is impossible if the timelock contract is coded properly.

Instead, we’re exploiting a vulnerability that exists at the mechanism-design level. In particular, we leverage the fact that almost all deployed smart contract timelock systems unintentionally allow for the trustless transfer of future ownership of the locked tokens without having to move the tokens.

Both attacks require the attacker to perform some setup before they become the beneficiary of the timelock contract. In particular, the attacker must first write an exploit contract and make sure that their exploit contract becomes the beneficiary of the timelock. So, if you have timelock contracts being used in production right now, and if it is not possible for the beneficiary addresses to be changed, then you don’t have to worry about your existing beneficiaries implementing this attack when they read this. They’ve either already gotten their exploit contracts listed as beneficiaries or they’ve missed their opportunity. But for any future timelocks you create, consider implementing the preventative measures described at the end of this article.

Finally, not all timelock mechanisms are intended to delay the divesting of interest. For example, some are used to impart delays to prevent race conditions or for other security reasons. The attacks described here are only for bypassing timelock incentive mechanisms and are unlikely to apply to timelocks used for other reasons. 

The basic timelock setup

The implementation details of timelocks can vary greatly, but they all have two important elements in common: a beneficiary and a releaseTime. The timelock mechanism sets aside some amount of ETH/ERC20 tokens in such a way that they can be received only by the beneficiary address and only after the releaseTime has come to pass.

For the remainder of this article, we’ll use the following simple timelock contract as an example. To keep the code simple, we’ll deal with timelocking ETH instead of ERC20 tokens, but the exact same principles can be used for token timelocks as well.

This contract locks some amount of ETH and releases it to a beneficiary at some time in the future.

 

 



This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters

 





pragma solidity ^0.5.0;
/**
* @title SimpleTimelock
* @dev SimpleTimelock is an ETH holder contract that will allow a
* beneficiary to receive the ETH after a given release time.
*/
contract SimpleTimelock {
// beneficiary of ETH after it is released
address payable public beneficiary;
// timestamp when ETH release is enabled
uint256 public releaseTime;
// accept incoming ETH
function () external payable {}
constructor (address payable _beneficiary, uint256 _releaseTime) public {
require(_releaseTime > block.timestamp, "release time is before current time");
beneficiary = _beneficiary;
releaseTime = _releaseTime;
}
// transfers ETH held by timelock to beneficiary.
function release() public {
require(block.timestamp >= releaseTime, "current time is before release time");
uint256 amount = address(this).balance;
require(amount > 0, "no ETH to release");
beneficiary.transfer(amount);
}
}

Note the following key properties of the SimpleTimelock contract. Any timelock contract with these 4 properties is susceptible to our attacks:

  1. The balance that will ultimately go to the beneficiary cannot decrease before the payout happens. In our example, the balance can increase, but that is not important. All that matters is that the balance cannot decrease.
  2. The beneficiary address cannot be changed by anyone who is not the beneficiary. In our example, it is also the case that the beneficiary herself cannot change the beneficiary address, but that is not important. All that matters is that anyone who is not the beneficiary cannot change the beneficiary address. 
  3. Nobody can prevent the beneficiary from releasing the ETH once the releaseTime has passed. In our example, since the release function is public, anyone can cause the release to happen, but that is not important. All that matters is that at a minimum the beneficiary is able to cause the funds to be released once the releaseTime has passed.
  4. The beneficiary address can be a contract address. There are no checks in place to ensure that the beneficiary is an externally owned account (EOA).

One way or another, the deployer of the timelock contract will collect a beneficiary address. Perhaps they do this automatically by grabbing msg.sender during a token sale. Or perhaps they simply ask the founder of the company to email them the address at which they want to receive their vesting tokens.

The important thing to point out here is that, while the deployer of the contract is the one passing the beneficiary parameter to the constructor function of the timelock contract, it is the beneficiary who ultimately decides what the beneficiary address will be.

In any case, the deployer collects the beneficiary address, deploys the timelock contract, and then funds the deployed contract.

Next, we look at two different attacks, a simple one and a more sophisticated one, that allow a malicious beneficiary to divest themselves of their timelocked ETH. That is, we’ll go over two different ways they can sell their timelocked ETH on the open market before the timelock has expired. Of course, these attacks can easily be adapted to ERC20 tokens or other timelocked assets.

A simple timelock bypass

Our attacker, Alice, is asked by the deployer of the SimpleTimelock contract for the address where she’d like to receive her ETH after the releaseTime expires in 2 years.
Alice deploys the following “Bypasser” contract and gives its address to the deployer of the SimpleTimelock contract. That is, Alice’s SimpleTimelock beneficiary becomes this contract:

 

 



This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters

 





pragma solidity ^0.5.0;
/**
* @title Bypasser
* @dev Bypasser is a malicious SimpleTimelock beneficiary that
* allows the owner to divest before the timelock has expired.
*/
contract Bypasser {
// owner of the contract
address payable public owner;
// price required to purchase ownership from current owner
uint256 public price;
// only owner can call functions with this modifier
modifier onlyOwner() {
require(msg.sender == owner, "only owner can call this");
_;
}
// accept ETH
function () external payable {}
constructor() public {
owner = msg.sender;
}
// allows current owner to collect any ETH in this contract
function collect() external onlyOwner {
owner.transfer(address(this).balance);
}
// allows the current owner to set the price of buying ownership
function setPrice(uint256 newPrice) external payable onlyOwner {
price = newPrice;
}
// allows anyone willing to pay the `price` to become the new owner
function buyOwnership() external payable {
require(price != 0, "cannot buy ownership when the price is 0");
require(msg.value >= price, "did not send enough funds");
uint256 pricePaid = price;
address payable oldOwner = owner;
// set price to zero
price = 0;
// set new owner
owner = msg.sender;
// pay the old owner
oldOwner.transfer(pricePaid);
}
}
view raw

Attack1.sol

hosted with ❤ by GitHub

A few things to note about this Bypasser contract:

  1. The contract has an owner, which starts off being Alice.
  2. At any time, the owner of the contract can call collect(), which transfers all ETH in the Bypasser contract to the current owner.
    This means that whoever the owner of the Bypasser contract is when the SimpleTimelock’s releaseTime has passed will be able to collect all the timelocked ETH!
  3. The owner can sell ownership of the contract to anyone. First, the owner calls setPrice to set the selling price. Then, anyone who wants to buy can call buyOwnership (while sending enough ETH to cover the price) and become the new owner.

With this contract in place as the SimpleTimelock beneficiary, all Alice has to do is publish & verify the SimpleTimelock and Bypasser contract code on Etherscan so that anyone can verify it.

After that, Alice should be able to (trustlessly) sell ownership of the Bypasser contract on the open market for a price equal to the discounted future value of the timelocked ETH, thereby divesting herself entirely of all her current interest in the timelocked tokens.

Understanding the simple bypass

Notice that Alice was able to divest herself of her interest even though the ETH never moved. She didn’t sell the timelocked ETH itself. She sold the future ownership of the ETH. The Bypasser contract didn’t magically make the timelocked ETH transferable — it made the future ownership of the timelocked ETH transferable.

The Bypasser contract is a trustlessly transferable and perfectly enforceable claim to the timelocked ETH. Alice divests by selling this claim on the open market. Since the claim is perfectly enforceable (at zero cost), the market price it ought to fetch should be the discounted future value of the timelocked ETH.

A more sophisticated bypass

It may be difficult for Alice to find potential buyers if the amount of ETH in the SimpleTimelock contract is very large. While selling a few hundred dollars worth of ETH to several small investors is easy, selling a large amount of ETH to a single large investor may require more effort. 

Also, Alice may not want to divest herself of all of her interest. Perhaps she just wants to partially divest, to sell only a fraction of her timelocked ETH.

Furthermore, in some situations, the amount of ETH in the SimpleTimelock contract may increase over time. In these cases, if Alice sells the Bypasser contract before the amount of ETH in the SimpleTimelock contract increases, Alice will have lost out on the value of that additional ETH (to the happy surprise of the person to whom she sold it). This is not ideal for Alice.

To resolve all of these problems, Alice can tokenize her claim to the locked ETH. That is, she can use a more sophisticated version of the Bypasser contract that mints one AliceCoin for every one ETH to which the beneficiary is entitled. This modified Bypasser contract would — after the releaseTime of the SimpleTimelock contract has passed — allow owners of AliceCoin to cash in their AliceCoins for an equal amount of ETH.

The following contract, TokenizedBypasser, is an example of how this can be done:

 

 



This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters

 





pragma solidity ^0.5.0;
import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v2.3.0/contracts/token/ERC20/ERC20.sol";
/**
* @title TokenizedBypasser
* @dev TokenizedBypasser is a malicious SimpleTimelock beneficiary that
* tokenizes its claim on the locked ETH.
*/
contract TokenizedBypasser is ERC20 {
using SafeMath for uint256;
// the SimpleTimelock contract we're bypassing
SimpleTimelock public simpleTimelockInstance;
// tracks whether the simpleTimelockInstance has been set
bool public instanceSet;
// owner of the contract
address payable public alice;
// accept ETH
function () external payable {}
constructor() public {
alice = msg.sender;
}
// allows alice to set the simpleTimelockInstance
function setSimpleTimelockInstance(SimpleTimelock _simpleTimelockInstance) external {
require(!instanceSet, "instance cannot be changed once set");
require(msg.sender == alice, "only alice can set the instance");
instanceSet = true;
simpleTimelockInstance = SimpleTimelock(_simpleTimelockInstance);
require(simpleTimelockInstance.beneficiary() == address(this),
"this contract is not the beneficiary of the passed instance"
);
}
// allows anyone to mint new coins, limited to the number of ETH that can be paid out
// new coins are always given to alice
function mintNewAliceCoin() external {
require(instanceSet, "cannot be called before an instance has been set");
uint256 maxAliceCoinAllowed = address(simpleTimelockInstance).balance.add(address(this).balance);
uint256 totalSupplyOfAliceCoin = totalSupply();
require(totalSupplyOfAliceCoin < maxAliceCoinAllowed, "cannot mint more AliceCoin right now");
uint256 amountToMint = maxAliceCoinAllowed.sub(totalSupplyOfAliceCoin);
_mint(alice, amountToMint);
}
// allows any AliceCoin holder to cash in their AliceCoin for ETH whenever this contract holds enough ETH to do so
// note that after the releaseTime this contract will always have access to enough ETH to payout all AliceCoin holders
function cashInAliceCoin() external {
uint256 amountToRelease = balanceOf(msg.sender);
require(amountToRelease <= address(this).balance, "this contract has not yet received enough of the released ETH");
// burn msg.sender's AliceCoin
_burn(msg.sender, amountToRelease);
// give msg.sender their ETH
msg.sender.transfer(amountToRelease);
}
}

So, Alice would deploy the TokenizedBypasser contract and give its address to the deployer of the SimpleTimelock contract. That is, this TokenizedBypasser contract would become the beneficiary of the SimpleTimelock contract.

Then, once the SimpleTimelock contract was deployed, Alice would pass its address to the setSimpleTimelockInstance function of the TokenizedBypasser contract.

From that point forward, anyone can cause the TokenizedBypasser contract to mint AliceCoin that is 100% backed by an equal amount of ETH. Also, any owner of AliceCoin can be 100% certain that they will be able to cash in their AliceCoin for an equal amount of ETH once the releaseTime has passed.

Additionally, if the SimpleTimelock contract receives any more ETH, the TokenizedBypasser will allow more AliceCoin to be minted in response.

In this way, Alice has created a new token — AliceCoin — that is fully backed by (and trustlessly redeemable for) her timelocked ETH. She can sell any portion of it she wants to other investors. Additionally, if at any time the SimpleTimelock contract receives more ETH, Alice will be able to use that to back the minting of more AliceCoin.

Holders of AliceCoin are 100% guaranteed that they’ll be able to cash in their AliceCoin for an equal amount of ETH once the releaseTime has passed. Therefore, the market price of a single AliceCoin should be the discounted future value of one timelocked ETH. Alice can thus divest her current interest in the timelocked ETH by seller her AliceCoins on the open market.

How we can prevent timelock bypassing

Recall from the “The basic timelock setup” section that the following four properties of the timelock contract are necessary for this attack to work:

  1. The balance that will ultimately go to the beneficiary cannot decrease before the payout happens.
  2. The beneficiary address cannot be changed by anyone who is not the beneficiary. 
  3. Nobody can prevent the beneficiary from receiving the ETH once the releaseTime has passed.
  4. The beneficiary address can be a contract address.

Removing any of these properties from our timelock contract will neutralize our bypass attack. However, properties 1-3 are almost always critical to the incentive alignment we’re attempting to achieve with the timelock to begin with. Therefore, the removal of those properties should be off the table.

That leaves property 4: The beneficiary address can be a contract address.

To prevent the bypassing attack without threatening our intended incentive alignment, we can simply modify the timelock contract so that the beneficiary address cannot be a contract address.

There are two common ways to do this:

Prevention Method #1: msg.sender == tx.origin

If the deployer of the timelock contract is setting the beneficiary of the timelock as the msg.sender of some transaction, then they can simply require msg.sender == tx.origin before setting the beneficiary to msg.sender. Since tx.origin is always an EOA, we know it cannot be a contract.

While this approach is simple, it has some downsides. First, it requires that the beneficiary has ETH (for gas) in their beneficiary address because they need to initiate a transaction in order to set the beneficiary address.

Second, it limits the design space of the timelock contract. In particular, it requires that the timelock (or some other contract) first be deployed and that the beneficiary sends a transaction to it.

Finally, Vitalik has suggested that developers should “NOT assume that tx.origin will continue to be usable or meaningful.” To remain future-proof, we should avoid leaning on tx.origin whenever possible.

Prevention Method #2 (recommended): ecrecover(m, v, r, s)

Rather than using msg.sender as the beneficiary address, the timelock contract can instead require that a message and signature be passed. The timelock contract can extract the beneficiary address from a valid signature using ecrecover. Since only EOAs can create valid signatures, this ensures that the beneficiary is not a contract. OpenZeppelin provides an ECDSA library with a recover function that wraps ecrecover and makes it easier to use.

This method is nice because it doesn’t require that the beneficiary address has ETH for gas. They can simply sign a message and pass it to the deployer of the timelock contract. It also gives the designer of the timelock contract more flexibility because they can collect and submit the signatures in any number of ways. They don’t necessarily have to set the beneficiary address at the same time the beneficiary sends a transaction to their contract.

An important caveat to this approach is that we should not allow the signed message, m, to be any arbitrary message. If we allow m to be any arbitrary message, then a malicious beneficiary could use Nick Johnson’s method to create a one-time-use EOA that can provably do only one thing: sign a transaction that forwards ETH to a Bypasser contract. If that signed transaction is used as the message m, then we end up right back where we started: The future control of the locked tokens would rest with a Bypasser contract. So we have to prevent the beneficiary from being a contract and also prevent it from being a one-time-use EOA that forwards ETH/tokens to a contract. There are two easy ways to do this.

First, you can simply require that the message being signed is a message that cannot be parsed as an Ethereum transaction. For example, you could require the beneficiary to sign the message, “This is an EOA.” Since a one-time-use EOA can sign only a single message, this would ensure that the signing address is not a one-time-use EOA that forwards to a contract.

Second, if for some reason you do want to accept arbitrary messages, you could simply require that the beneficiary sign two distinct messages. Since one-time-use EOAs cannot sign more than one message, we know that any address that signs two distinct messages is a non-one-time-use EOA.

Finally, remember that any time you are using ecrecover you should always require that the signer present the original message being signed — not just the hash. If the signer is allowed to present a signed hash without also presenting its preimage then they can produce “valid” signatures for any address.

OpenZeppelin Contracts may soon be adding EOA-verification functionality, so you’ll be able to implement these protections easily.

Limitations on prevention

It is always possible for a beneficiary of a timelock to perform an attack similar to this (assuming properties 1-3 of the “The basic timelock setup” hold), even if their beneficiary address is a non-one-time-use EOA. For example, the beneficiary can create a traditional legal contract (no blockchain required) that promises their timelocked tokens to some third party. There is nothing any smart contract could do to prevent that.

However, such traditional methods are not trustless. They depend on the enforceability of (and the cost of enforcing) the legal contract. These limitations would negatively affect the price Alice could fetch for her locked coins as well as greatly limit the number of investors to whom she could sell. That being said, these more traditional bypasses are always possible and cannot be prevented with code.

On the other hand, the trustless bypassing methods are preventable using the recommendations we mentioned above.

Credit where it’s due

I believed this is a little-known idea. However, it is certainly not an original idea. Indeed, I have a vague memory of someone performing an attack like this during one of the late 2017 ICOs and then posting an offer to sell his “claim” to the timelocked tokens on Twitter. The idea has stuck with me ever since, and I only recently decided to write about it. Despite my best efforts, I’ve been unable to find those old tweets or any other examples of this attack being performed in the wild. However, I know they’re out there.

If you can point to any examples of this kind of attack being performed in the wild, please let us know in the comments!

Conclusion

Any timelock incentive mechanism that has the four key properties listed in the “The basic timelock setup” section can be bypassed by malicious beneficiaries. We can prevent the most damaging and easy-to-execute versions of these bypassing attacks by requiring that all beneficiary addresses be non-one-time-use EOAs.