Bringing back dynamism to EVMScripts (+ Delegatecall'ing into untrusted contracts)


#1

EVMScripts in aragonOS allow forwarders to execute actions when some conditions are met. They are used by Aragon apps to perform actions that are approved by arbitrary logic, generally as the result of an account having some privilege (owning a token or being on a list) or when the governance mechanism allows it.

DAOs can install different EVMScriptExecutors that parse and execute scripts in different ways. In aragonOS 3, we introduced 3 types of executors: CallsScript, DelegateScript and DeployDelegateScript.

  • CallsScript takes an array of addresses and calldata and executes them in an atomic manner (either all calls are successfully performed, or the entire execution is reverted). Because a CallsScript just executes whatever calls it was provided with when it was created and it doesn’t allow to use the return data from one call as an input to the following ones, therefore it can only execute fairly simple sets of actions.

  • DelegateScript and DeployDelegateScript were designed to allow for actual dynamic scripts that can execute in different ways depending on context available when the script is run. This context can be sourced from return data of calls the script makes, or the storage of the contract that executes the script. For simplicity, we decided on using the EVM itself as the ‘interpreter’ of these scripts rather than building one from scratch. In order to use the EVM, these scripts executors would delegatecall into an inputed account (in the case of DelegateScript) or create a contract and delegatecall to it (DeployDelegateScript).

Even though some protections were in place to avoid script contracts to selfdestruct or modify critical storage, the WHG audit revealed a way to bypass our protection against scripts selfdestructing that was undetectable. While it is possible to be certain that a contract didn’t destruct itself in the immediate child execution context (if more than 0 bytes of data are returned from the call), a malicious script could delegatecall into itself, selfdestruct and then have the top-level child call return some data. Given these findings, we decided to deprecate and remove these two executors as we couldn’t ensure their security.


Even though dynamic scripts cannot be executed for the time being, they are still an important idea worth bringing back, giving the flexibility they provide for executing complex actions after a governance decision is made. An immediate need for dynamic scripts are app installation scripts that cannot be executed just with CallsScripts or potential storage migrations on app upgrades that could require direct access to storage in order to perform a migration.

Below are 3 different avenues of how dynamic scripts could be brought back with extra security assurances:

  • DelegateScript + Opcode Checker: by using an on-chain opcode checker like this contract Nick Johnson built, before executing the script, all the opcodes the target contract has can be checked to ensure that some opcodes that could be potentially malicious are not in the contract. For removing the possibility of scripts causing a contract to selfdestruct, one just needs to check that there are no selfdestruct or delegatecall opcodes in the target, if no storage modifications are desired either, a check for no sstore can be added. There is some computation overhead in analyzing the contract for the first time (which grows linearly with code size), but after doing it once all its opcodes are cached in a bitmask, that can very cheaply used to find the inclusion or exclusion of any number of opcodes.

  • Return by reverting + safe execution (technique introduced by auth-os and brought to my attention by Alex Wade): doing a delegatecall to an untrusted contract is not dangerous if the call reverts, as all the state changes that could have been done are reverted, but the call can return some data when reverting. This could allow for executing scripts without any side-effects, that then can return some instructions for the executor to perform. Scripts would be executed in two steps, the first one to calculate the calls or storage modifications to be made at execution time, and then the actions would be performed in the trusted environment of the executor that can enforce extra checks such as not allowing to modify certain storage slots or not making calls to address in the address blacklist.

  • CallsScript + memory + evaluable expressions: a superset of CallsScript can be built that can save return data from calls to memory, and then implement conditional statements, transformations of memory values and using memory values as either the target or parameters in the calls made. This would be by far the most complex solution, as it would require building a limited VM inside the EVM.


#2

Extending this topic to general delegatecall security, which is very relevant for DelegateProxies, there are some changes to how the EVM works that would provide some desired extra security guarantees. All of them would require a hard fork, so mainly doing a brain dump to see if any of these would make sense to push forward as an EIP. Credits to Jordi and Adria for starting the conversation about this:

1. WILLSELFDESTRUCT opcode

This one would be the simplest to implement. This opcode would take one value from the stack and push a 0 or a 1 depending on whether the inputed address has performed a selfdestruct during the execution of the transaction, and it is on the list of contracts that will be removed at the end of the execution. After doing a delegatecall, the contract could use this opcode to check if it has been marked to be destructed, and have an opportunity to revert the execution.

Note that this mechanism could prevent proxies and script executors from being destructed, but the base contracts could still be killed as in the 2nd Parity Wallet issue. In the case of Aragon this would only be an issue when using AppProxyPinned which removes the ability to upgrade base contracts using the kernel.

2. Indestructible contracts (CREATE2 + indestructible)

An extra parameter could be added to CREATE2 (which is scheduled to be added in the Constantinople HF), to specify if the contract that is created can be destructed or not. This value would need to be stored in the account trie (can’t use a storage slot as it could be modified).

If a contract marked as indestructible executes the selfdestruct opcode, the call would revert. Another option, for better backwards compatibility, could be to make a transfer of the balance of the account + a noop (instead of destructing the contract).

Another possible implementation for this would be to make all contracts created (with any of the two opcodes) after a particular fork block number indestructible. But there is no way of doing this without introducing significant problems, as there are contracts that work under the assumption that by doing a selfdestruct the balance is transferred to an address and the contract is removed. For example, ENS Deed contracts, which is created every time someone bids for a name, are continuously created (before and after the hypothetic fork) and rely on selfdestruct for closing them.

If a new opcode was added to check whether an account is indestructible, we could require that proxies’ base contracts or scripts are indestructible.

3. Conditional delegatecall

A variant of the delegatecall opcode could be introduced that takes an extra value from the stack which is an opcode blacklist bitmask. This bitmask specifies which opcodes cannot be used during the call (if bit x of the bitmask is 0, opcode x can be used, and if it is 1 then it cannot). In case an opcode that is not allowed in the call is used, the call should revert.

This is a similar approach to the Opcode checker detailed above, but with the advantage that the target contract could have ‘blacklisted opcodes’ in its code and as long as they are not used during the call there would be no problem.

This approach is interesting as it could secure against other threats that are not just a selfdestruct, such as preventing a delegatecall from doing an SSTORE.


#3

Following the idea of the Conditional delegatecall, it also could be usefull to add protection for storage slots. Something like DELEGATECALL2 ... 0xOPBITMASK PROTECTEDSLOTLIMIT where all attempts to SSTORE in slots lower than PROTECTEDSLOTLIMIT will fail.