A Frustrating Assumption Keeping Ethereum Secure

Ashley Houston
10 min readJun 22, 2019

In writing an unrelated article, I had to touch on The Assumption built into the Ethereum ecosystem. Here I want to basically rant about why The Assumption is bad. So, what is it? The Assumption is that in order to send an Ethereum smart contract ETH and also have no problems with reentrancy exploits, the gas limit for a called smart contract shall be 2300 (or less).

Magic Numbers and STATICCALL

In every single modern (iirc, after Solidity 3.0) smart contract which sends payment using the transfer function, there exists this hardcoded constant, 2300. For instance, this simple example:

contract Tester{
function() external{
address payable paymentAddress = 0x5A0b54D5dc17e0AadC383d2db43B0a0D3E029c4c ;
paymentAddress.transfer(5);
}
}

The transfer bit is translated to EVM bytecode as

CALL 2300, address, ....

Why is this number so important? Well, it was determined that for reasons, this was to be the most effective way to prevent a huge class of smart contract bugs categorized as “reentrancy”. Reentrancy is the concept of a smart contract calling another smart contract, which eventually (in the same execution) calls the original smart contract again. Reentrancy is the infamous issue which was the primary exploit used in the DAO hack. The solution proposed at the time was not to change the Ethereum protocol to allow contracts to prevent it. Rather, the solution ended up being to change Solidity so that the default behavior when sending ETH to a smart contract, was to use so little gas that reentrancy problems could not be exploited. And of course, as a side-effect, it then prevented smart contracts receiving ETH from changing any state or really doing anything other than logging an event.

BUT EARLZ, I hear the internet cry. Recently Ethereum introduced STATICCALL, the magic cure-all for preventing reentrancy issues. Well, yes.. but also, hahahahaha no. First, lets try to force Solidity to use STATICCALL on our test contract’s payback function:

pragma solidity ^0.5.9;contract Tester{
function() external view{
}
function foo() external view{
}
}

And the compiler rewards our adventurous spirit with the following error:

browser/test.sol:4:5: TypeError: Fallback function must be payable or non-payable, but is "view".
function() external view{
^ (Relevant source part starts here and spans across multiple lines).

Oh, and fun fact, there is no explicit “non-payable” keyword to explicitly mark a function as non-payable. Let’s remove the view from the fallback function and check the ABI of this:

[
{
"constant": true,
"inputs": [],
"name": "foo",
"outputs": [],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"payable": false,
"stateMutability": "nonpayable",
"type": "fallback"
}
]

So Solidity, in it’s infinite wisdom, decided that stateMutability for a fallback function shall only be nonpayable or payable. The value "view" is not allowed here.

But let’s pretend Solidity isn’t awful and actually were to allow this. You could then tell Solidity to transfer money to the contract’s fallback function using STATICCALL, and all would be well right? Well, wrong. STATICCALL is peculiar in it’s exact design. Sure, you could actually use the fallback function for logic, but STATICCALL is designed to allow an external contract call to have no side-effects, and instead to only return some data that is the result of whatever computation it did. Fallback functions don’t really have the concept of returning data, though it is possible if you drop down to assembly on both the caller and callee side. So STATICCALL is actually pretty useless for this unless you’re doing some really weird stuff.. and if you’re doing that, why not just do a regular function call instead of using the fallback function. But I digress.

STATICCALL’s emphasis is the “no side-effects” part. This means you can’t do any of the following:

  • Modify state
  • Call another contract which modifies state
  • Create a contract
  • Self-destruct a contract
  • Log events
  • Send ETH to other contracts, or otherwise modify a contract’s balance
  • Receive ETH as a contract being STATICCALL’d

Not being capable of modifying state is what you would expect. This aspect alone prevents reentrancy bugs directly. I didn’t expect for logging events to be disallowed though. Logging events have no actual side effects that are visible to smart contracts. Once an event is logged, it’s not possible for an external (or the internal) smart contract to see that a state was logged, it’s completely a “null output”. You send data into space, but can never receive that data again, nor even observe that the data was once sent. The side effect is only visible to the world outside of the blockchain. Events are commonly used as a way to tell external interfaces into the blockchain “hey something you might be interested in happened here”

So, in what I can only assume is in the name of technical purity, STATICCALL takes the “no side-effects” rule very strictly. No side-effects including side-effects only visible externally. The other aspects of course being that ETH can not be moved around inside of STATICCALL. This effectively breaks it as a contendor for the solution to reentrancy problems. Typically when calling a fallback function you either 1) made a mistake, or 2) are sending a contract ETH. And when a contract receives ETH, it usually will log an event to tell external programs “hey I received a deposit, you might want to react to this and show the user a message or something”. With the magical 2300 gas limit, it is not possible send a portion of the ETH to another contract, nor is it possible to set some state within the contract, such as updating an “expected balance” variable or something. STATICCALL’s only use is in preventing reentrancy from generalized functions where you do not intend to send the contract ETH.

This means, the only effective way to prevent reentrancy, but also send ETH and allow an event to be created, is still the old way. Using the magic gas limit constant 2300. Why is this such a big problem though? It’s not like hardcoded numbers are regarded as a bad thing in computer science (hint: they are). It turns out that hard coded numbers can actually cause some contracts to become provably broken when changes are required to the surrounding Ethereum ecosystem.

Constants in a Dynamic Blockchain

This helpful reddit post details some problems with an older fork in Ethereum which increased the minimum cost of a CALL in Ethereum (the minimum if I recall correctly was 100 and is now 500) as well as other opcodes. Mst of these concerns still apply to literally any gas price adjustment in the future. Good thing Nick Johnson pointed out that “… uses of calls with explicit gas limits are very rare.” At the point this quote was said, Solidity would pass all gas possible to a contract when doing the equivalent of a transfer, of course leaving the reentrancy category of exploits possible. Solidity introduced the 2300 gas limit default later on in order to kill the exploit potential when the DAO hack occurred. Now, to be fair, by default when calling an external contract function (not using transfer) the default behavior of Solidity is still to send all gas, and there are copious amounts of warnings in documentation around the exploit potential of this. This magical 2300 gas limit constant has only intensified the issues pointed out in the reddit post.

For instance, imagine a contract with a payable fallback function that uses some opcode that is, at the time of deployment, cheap and executes within the default 2300 gas limit. However, later on, the price of that opcode is significantly increased in response to an attack or previously unknown vulnerability. That contract will now be broken and incapable of receiving ETH from contracts which do not explicitly specify a higher gas limit than 2300 (which Solidity warns is A Bad Idea). Worse, explicitly increasing the gas limit then exposes the calling contract to reentrancy exploits. So, the contract pretty much must be deprecated, and depending on the exact logic around how it received ETH (such as relying on a particular contract to send it ETH), it may now be bricked, with money locked away inside that is completely and provably inaccessible.

This 2300 gas limit assumption isn’t just harmful to backwards compatibility though. It also harms the potential for future innovations within the Ethereum Protocol. For example, EIP 1283, Net gas metering for SSTORE is an innovative protocol improvement which would reduce the cost of many smart contract patterns involving storage. It effectively makes the gas cost of storage reflect the actual cost on the blockchain, meaning that the second time a storage key is written in an execution it will consume less gas. This makes sense because the second state modification is nearly free from the point of view of the blockchain, as the first state modification already paid the blockchain cost for this to be modified. It was included in the Constantinople fork but had to be removed at the last minute due to a discovered vulnerability vector that would’ve affected a large number of existing smart contracts. That’s right, this reduction in state storage cost would’ve brought back the reentrancy class of exploits, even with the very conservative 2300 gas limit. The ironic thing about this is that the proposal’s design actually would reduce the gas cost of reentrancy protection in smart contracts, and was this use case was one of it’s major benefits.

Currently, the proposed solution to the reentrancy problem with EIP 1283 is EIP-1706. The basic summary of the changes in this proposal is that the Net Gas Metering discounts will not take effect if the gas limit of the current execution is below 2300. So now, this magic constant could become engrained into the Ethereum consensus protocol itself. This would effectively force any future EVM language to also use the hardcoded 2300 gas limit for contract calls to prevent reentrancy exploits.

This magic assumption basically prevents storage from ever being made cheaper, regardless as to what Ethereum does in the future to fix scaling problems and everything else. If a magic way was invented to store everything off-chain so that storage was effectively free, the actual gas cost of storage could still not be made cheaper than the magic 2300 gas limit constant, or else expose reentrancy exploits.

Potential Solutions

I’ve done a lot of complaining, but this is a difficult issue right? I mean, we’re talking about blockchain, and everything blockchain is hard right? Well, yes, that’s true, but also I tend to disagree with the “technical purity” often enforced by the Ethereum team working on the consensus protocol. In fact, I doubt that EIP-1706 will be accepted into the Ethereum mainnet because of the hardcoded number issue, it’s not pure enough. My personal prediction is that EIP 1283 will be delayed indefinitely, maybe to be added in Ethereum 2.0 eventually.

How would I solve the reentrancy problem? Well, two potential ways with the first one being the easiest. Add yet another opcode, but this time, make it useful. Here I propose the following opcode:

MAGICCALLWITHOUTREENTRANCYEXPLOITS

It’s really a concise name that rolls off the tongue. But seriously, this opcode would basically behave the same as CALL but with the following changes:

  • SSTORE (state modification) would not be allowed, like in STATICCALL
  • literally everything else is still allowed

I don’t really like this solution though honestly. I’d rather fix the root problem, allowing reentrancy into any smart contract. Ideally, there would instead be a very simple opcode like

KILLMEIFREENTRANT

That will stop execution if the current contract is already within the call stack. This is a thing that could be implemented in 1 night by an experienced developer, and tested for security in 1 day. This allows for reentrancy prevention to not involve storage at all, and could be done very cheaply with a simple if callstack.exists(currentAddress) then throw. But again, I feel like such an opcode would never be considered "pure" enough to be adopted into Ethereum.

Alternatives that are more pure though are things like, idk, exposing the actual call stack to smart contracts. With knowledge of what the call stack contains, a solidity function could be written pretty simply to iterate over the stack and check if it’s own address is contained within it, proving that the current execution is reentrant. And of course, if that isn’t expected, the contract would then throw an exception to prevent any undesirable or unexpected behavior. It would also allow for other features to be implemented within smart contracts. For instance, imagine you have a crowdsale that smart contracts can participate in. However, you blacklist a certain smart contract associated with terrorists. The terrorists could simply deploy a “pass through” smart contract and then have the blacklisted smart contract, call the pass through contract which then calls your crowdsale smart contract. With call stack awareness, it would be possible to spot such behavior. Currently in Ethereum, detecting this is completely impossible on-chain with smart contract logic.

Currently reentrancy prevention implementations in Ethereum are almost always a security risk in themselves. Typically, a variable is set to 1 upon contract execution, indicating an execution is being done. When the execution is complete, the variable is reset to 0. In this way, if you do a contract call in the middle of that, if the external contract tries to reenter the current contract, the contract will see this variable is set to 1 and abort execution. However, what happens if there is some logic problem and the execution ends without setting that variable back to 0? Basically the smart contract becomes bricked and completely inoperable, because it constantly thinks that it is under attack.

Reentrancy has been one of the biggest and most talked about issues in the Ethereum ecosystem and has been listed on several sites as the #1 security problem to be careful of when creating smart contracts. This is what caused the DAO hack, as well as several other attacks and unexpected behavior. It is also one of the hardest problems to correctly handle as a smart contract developer, hence why most smart contracts simply prevent it completely. It makes no sense to me that Ethereum has not implemented a direct way to prevent reentrancy. Rather the preference seems to be relying on the greatly limited STATICCALL mechanism, or this magical 2300 gas limit constant. It deserves a first class solution in my mind, not an ugly hack based on assumptions.

Disclaimer: I’m the Co-founder and Lead Developer of Qtum, a blockchain commonly known to be a competitor to Ethereum, despite our different ideals and directions. We implement the EVM in our own blockchain, and thus are also subject to the issues described here, which is part of the inspiration for ranting so much about them. Anything I say here is “unofficial” and of my own opinion.

--

--

Ashley Houston

(archived) Blockchain Engineer, co-founder at Qtum, President of Earl Grey Tech. All in on blockchain tech. Also does some film photography