Destroying the Indestructible
This morning, I saw a link to Dharma’s
IndestructibleRegistry. The idea behind this registry is that it keeps track of contracts that cannot be destroyed. It does this by verifying the contract’s bytecode on chain. In this post, I’ll show you how I managed to trick that verification and destroy an “indestructible” contract.
The core of the contract is the
_isPotentiallyDestructible() function. That function fetches the code for a given address and iterates over the bytecode according to the following algorithm:
- If the current opcode is
DELEGATECALL, the contract can be destroyed, so fail verification.
- If the current opcode is something that stops execution (like
JUMP), skip everything until the next
- If the current opcode is a
PUSH, skip over the push data.
The algorithm ignores push data to avoid flagging innocent things like
PUSH1 0xFF. (
0xFF is the bytecode for
SELFDESTRUCT, but here it’s just a value being pushed onto the stack.) The code also skips over unreachable code and resumes analysis when a
JUMPDEST is found. This avoids flagging things like the metadata hash Solidity inserts into contract code.
A First Attempt
I initially thought I could fool the analysis with the following simple bytecode. It attempts to use push data to hide the malicious code:
0x00 PUSH1 0x01 0x04 // jump destination 0x02 JUMP 0x03 PUSH3 0x04 JUMPDEST 0x05 CALLVALUE // just get a 0 on the stack, the selfdestruct destination 0x06 SELFDESTRUCT // hidden in push data
However, as @z0age pointed out to me, section 9.4.3 of the Ethereum Yellow Paper specifies that a
JUMPDEST inside push data is invalid. A viable exploit has to pass
IndestructibleRegistry’s checks but also be executable by an Ethereum node, so this won’t work.
I dug around in the Geth source code and found validJumpdest() and codeBitmap(), which implement this logic in Geth.
The Geth logic for determining whether a
JUMPDEST is valid is quite simple. It first determines which bytes of the code are non-executable data as follows:
- If the current opcode is a
PUSH, mark the associated push data as data.
- Otherwise, just move on.
JUMPDEST is then valid if it’s not marked as data.
Finding the Real Exploit
Armed with a better understanding of both
IndestructibleRegistry and how jump destinations are validated, I looked closely to see how the two algorithms differed.
The significant difference is how
IndestructibleRegistry analyzes unreachable code. When it determines that some code is unreachable, it scans it byte by byte looking for
JUMPDESTs and then goes back into “reachable” mode. This byte-by-byte scan is not how Ethereum nodes analyze the code when looking for valid jump destinations.
Ethereum nodes don’t do reachability analysis at all, but
IndestructibleRegistry does this to avoid noise when the dangerous opcodes occur in innocuous places, like the metadata hash the Solidity compiler includes in the bytecode.
This difference lets us trick the registry into performing a different analysis than an Ethereum node in an exploitable way.
Here’s the exploit code, annotated from the perspective of
0x00 PUSH1 0x01 0x06 0x02 JUMP // get us into "unreachable" mode 0x03 PUSH2 // unreachable code that isn't a JUMPDEST: ignored 0x04 JUMPDEST // back out of "unreachable" mode 0x05 PUSH3 // skip the next 3 bytes of push data 0x06 JUMPDEST 0x07 CALLVALUE 0x08 SELFDESTRUCT
Here’s the same code, annotated from the perspective of an Ethereum node:
0x00 PUSH1 0x01 0x06 0x02 JUMP 0x03 PUSH2 // skip the next 2 bytes of push data 0x04 JUMPDEST 0x05 PUSH3 0x06 JUMPDEST // valid jump destination 0x07 CALLVALUE 0x08 SELFDESTRUCT
I was able to deploy that code to the blockchain, register it with
IndestructibleRegistry, and finally self-destruct it.
There are a number of reasons why something like the
IndestructibleRegistry could be useful. From a security perspective, one important reason is to make sure that a contract’s code can’t change. Since the introduction of the
CREATE2 opcode, it’s possible to deploy new contract code to the same address.
In our recent audit of the Orchid Network Protocol, we came across a potential need for this type of verification. In that system, a server needed to ensure that a smart contract will, at some point in the future, return a certain value when passed a certain set of parameters. To know what a contract will do in the future, you need to know (among other things) that the code cannot be changed. If the contract can be destroyed, the code can change, which means the future behavior is unknowable.
This vulnerability is low impact. In the case of Dharma, it’s not being used for anything security critical; it’s just an extra layer of assurance that their implementation contracts can’t be destroyed. In fact, the
IndestructibleRegistry contract is listed as the lowest priority in the Dharma Smart Wallet audit conducted by Trail of Bits.
It’s difficult to know how others may be using the contract, but there’s already a fixed version available, and
indestructible.eth now points there instead of the vulnerable version.
Is the New Version Safe?
You tell me! The code is tricky enough that it wouldn’t surprise me to find another vulnerability in the fixed code. I had fun figuring out the exploit in the original code, so I recommend others who are interested to give it a try with the new code.
Please share what you come up with!
The code for
IndestructibleRegistry is quite similar to another (insecure) attempt at doing the same thing, which my colleague @GNSPS pointed me to during a recent audit. Kudos to @recmo for discovering the flaw in that code.
Kudos also to @z0age, one of the authors of
IndestructibleRegistry. He was receptive and helpful as I worked through finding an exploit, and he quickly deployed a fix.
Thinking about smart contract security? We can provide training, ongoing advice, and smart contract auditing. Contact us.