Building Secure Smart Contracts

Brought to you by Trail of Bits, this repository offers guidelines and best practices for developing secure smart contracts. Contributions are welcome, you can contribute by following our contributing guidelines.

Table of Contents:

License

secure-contracts and building-secure-contracts are licensed and distributed under the AGPLv3 license. Contact us if you're looking for an exception to the terms.

List of Best Practices for Smart Contract Development

Blockchain Maturity Evaluation

  • Document version: 0.1.0

This document provides criteria for developers and security engineers to use when evaluating a codebase’s maturity. Deficiencies identified during this evaluation often stem from root causes within the software development life cycle that should be addressed through standardization or training and awareness programs. This document aims to push the industry towards higher quality requirements and to reduce risks associated with immature practices, such as the introduction of bugs, a broken development cycle, and technical debt.

The document can be used as a self-evaluation protocol for developers, or as an evaluation guideline for security engineers.

As technologies and tooling improve, standards and best practices evolve, and this document will be updated to reflect such progress. We invite the community to open issues to provide insights and feedback and to regularly revisit this document for new versions.

Rating system

This Codebase Maturity Evaluation uses five ratings:

  • Missing: Not present / not implemented
  • Weak: Several and/or significant areas of improvement have been identified.
  • Moderate: The codebase follows adequate procedure, but it can be improved.
  • Satisfactory: The codebase is above average, but it can be improved.
  • Strong: Only small potential areas of improvement have been identified.

How are ratings determined? While the process for assigning ratings can vary due to a number of variables unique to each codebase (e.g., use cases, size and complexity of the codebase, specific goals of the audit, timeline), a general approach for determining ratings is as follows:

  • If “Weak” criteria apply, “Weak” is applied.
  • If none of the “Weak” criteria apply, and some “Moderate” criteria apply, “Moderate” can be applied.
  • If all “Moderate” criteria apply, and some “Satisfactory” criteria apply, “Satisfactory” can be applied.
  • If all “Satisfactory” criteria apply, and there is evidence of exceptional practices or security controls in place, “Strong” can be applied.

Arithmetic

Weak

A weak arithmetic maturity reflects the lack of a systematic approach toward ensuring the correctness of the operations and reducing the risks of arithmetic-related flaws such as overflow, rounding, precision loss, and trapping. Specific criteria include, but are not limited to, the following:

  • No explicit overflow protection (e.g., Solidity 0.8 or SafeMath) is used, and no justification for the lack of protection exists.
  • Intentional usage of unchecked arithmetic is not sufficiently documented.
  • There is no specification of the arithmetic formulas, or the specification does not match the code.
  • No explicit testing strategy has been identified to increase confidence in the system’s arithmetic.
  • The testing does not cover critical—or several—arithmetic edge cases.

Moderate

This rating indicates that the codebase follows best practices, but lacks a systematic approach toward ensuring the correctness of the arithmetic operations. The code is well structured to facilitate the testing of operations, and multiple testing techniques are used. Specific criteria include, but are not limited to, the following:

  • None of the weak criteria apply to the codebase.
  • Unchecked arithmetics are minimal and justified, and extra documentation has been provided.
  • All overflow and underflow risks are documented and tested.
  • Explicit rounding up or down is used for all operations that lead to precision loss.
  • All rounding risks are documented and described in the specification.
  • An automated testing technique is used for arithmetic-related code (e.g., fuzzing, formal methods).
  • Arithmetic operations are structured through stateless functions to facilitate their testing.
  • System parameters are bounded, the ranges are explained, and their impacts are propagated through the documentation/specification.

Satisfactory

Arithmetic-related risks are clearly identified and understood. A theoretical analysis ensures that the code is consistent with the specification. Specific criteria include, but are not limited to, the following: The system meets all moderate criteria.

  • Precision loss is analyzed against a ground-truth (e.g, using an infinite-precision arithmetic library), and the loss is bounded and documented.
  • All trapping operations (overflow protection, divide by zero, etc.) and their impacts are identified and documented.
  • The arithmetic specification is a one-to-one match with the codebase. Each formula relevant to the white paper/specification has a respective function that is easily identifiable.
  • The automated testing technique(s) cover all significant arithmetic operations and are run periodically, or ideally in the CI.

Auditing

“Auditing” refers to the proper use of events and monitoring procedures within the system.

Weak

The system has no strategy towards emitting or using events. Specific criteria include, but are not limited to, the following:

  • Events are missing for critical components updates.
  • There are no clear or consistent guidelines for event-emitting functions.
  • The same events are reused for different purposes.

Moderate

The system is built to be monitored. An off-chain infrastructure for detecting unexpected behavior is in place, and the team can be notified about events. Clear documentation highlights how the events should be used by third parties. Specific criteria include, but are not limited to, the following:

  • None of the weak criteria apply to the codebase.
  • Events are emitted for all critical functions.
  • There is an off-chain monitoring system that logs events, and a monitoring plan has been implemented.
  • The monitoring documentation describes the purpose of events, how events should be used, and their assumptions.
  • The monitoring documentation describes how to review logs in order to audit a failure.
  • An incident response plan describes how the protocol’s actors must react in case of failure.

Satisfactory

The system is well monitored, and processes are in place to react in case of defect or failure. Specific criteria include, but are not limited to, the following: The system meets all moderate criteria.

  • The off-chain monitoring system triggers notifications and/or alarms if unexpected behavior or events occur.
  • Well-defined roles and responsibilities are defined for cases where unexpected behavior or vulnerabilities are detected.
  • The incident response plan is regularly tested through a cybersecurity incident response exercise.

Authentication / access controls

“Authentication / access controls” refers to the use of robust access controls to handle identification and authorization and to ensure safe interactions with the system.

Weak

The expected access controls are unclear or inconsistent; one address may be in control of the entire system, and there is no indication of additional safeguards for this account. Specific criteria include, but are not limited to, the following: No access controls are in place for privileged functions, or some privileged functions lack access controls.

  • There are no differentiated privileged actors or roles.
  • All privileged functions are callable by one address, and there is no indication that this address will have further access controls (e.g., multisig).

Moderate

The system adheres to best practices, the major actors are documented and tested, and risks are limited through a clear separation of privileges. Specific criteria include, but are not limited to, the following: None of the weak criteria apply to the codebase.

  • All privileged functions have some form of access control.
  • The principle of least privilege is followed for all components.
  • There are different roles in the system, and privileges for different roles do not overlap.
  • There is clear documentation about the actors and their respective privileges in the system.
  • Tests cover every actor-specific privilege.
  • Roles can be revoked (if applicable).
  • Two-step processes are used for privileged operations performed by Externally Owned Accounts (EOA).

Satisfactory

All actors and roles are clearly documented, including their expected privileges, and the implementation is consistent with all expected behavior and thoroughly tested. All known risks are highlighted and visible to users. Specific criteria include, but are not limited to, the following:

  • The system meets all moderate criteria.
  • All actors and roles are well documented.
  • Actors with privileges are not EOAs.
  • Leakage or loss of keys from one signer or actor does not compromise the system or affect other roles.
  • Privileged functions are tested against known attack vectors.

Complexity management

“Complexity management” refers to the separation of logic into functions with a clear purpose. The presence of clear structures designed to manage system complexity, including the separation of system logic into clearly defined functions, is the central focus when evaluating the system with respect to this category.

Weak

The code has unnecessary complexity (e.g., failure to adhere to well-established software development practices) that hinders automated and/or manual review. Specific criteria include, but are not limited to, the following:

  • Functions overuse nested operations (if/then/else, ternary operators, etc.).
  • Functions have unclear scope, or their scope include too many components.
  • Functions have unnecessary redundant code/code duplication.
  • Contracts have a complex inheritance tree.

Moderate

The most complex parts of the codebase are well identified, and their complexity is reduced as much as possible. Specific criteria include, but are not limited to, the following: None of the weak criteria apply to the codebase.

  • Functions have a high cyclomatic complexity (≥11).
  • Critical functions are well scoped, making them easy to understand and test.
  • Redundant code in the system is limited and justified.
  • Inputs and their expected values are clear, and validation is performed where necessary.
  • A clear and documented naming convention is in place for functions, variables, and other identifiers, and the codebase clearly adheres to the convention.
  • Types are not used to enforce correctness.

Satisfactory

The code has little or no unnecessary complexity, any necessary complexity is well documented, and all code is easy to test. Specific criteria include, but are not limited to, the following:

  • The system meets all moderate criteria.
  • Each function has a specific and clear purpose and is clearly documented.
  • Core functions are straightforward to test via unit tests or automated testing.
  • There is no redundant behavior.

Decentralization

“Decentralization” refers to the presence of a decentralized governance structure for mitigating insider threats and managing risks posed by privileged actors. Decentralization is not required to have a mature smart contract codebase, and a project that does not claim to be decentralized might not fit within this category. However, if a single point of failure exists, it must be clearly identified and proper protections must be put in place.

A note on upgradeability: Upgradeability is often an important feature to consider when reviewing the decentralization of a system. While upgradeability is not, at a fundamental or theoretical level, incompatible with decentralization, it is, in practice, an obstacle in realizing robust system decentralization. Upgradeable systems that aim to be decentralized have additional requirements to demonstrate that their upgradeable components do not impact their decentralization.

Weak

The system has several points of centrality that may not be clearly visible to the users. Specific criteria include, but are not limited to, the following:

  • Critical functionalities are upgradable by a single entity (e.g., EOA, multisig, DAO), and an arbitrary user cannot opt out from the upgrade or exit the system before the upgrade is triggered.
  • A single entity is in direct control of user funds.
  • All decision making is controlled by a single entity.
  • System parameters can be changed at any time by a single entity.
  • Permission/authorization by a centralized actor is required to use the contracts.

Moderate

Centralization risks are identified, justified and documented, and users might choose to not follow an upgrade. Specific criteria include, but are not limited to, the following:

  • None of the weak criteria apply to the codebase.
  • Risks related to trusted parties (if any) are clearly documented.
  • Users have a documented path to opt out of upgrades or exit the system, or upgradeability is present only for non-critical features.
  • Privileged actors are not able to unilaterally move funds out of, or trap funds in, the protocol.
  • All privileges are documented.

Satisfactory

The system provides clear justification to demonstrate its path toward decentralization. Specific criteria include, but are not limited to the following:

  • The system meets all moderate criteria.
  • The system does not rely on on-chain voting for critical updates, or it is demonstrated that the on-chain voting does not have centralization risks. On-chain voting systems tend to have hidden centralized points and require careful consideration.
  • Deployment risks are documented.
  • Risks related to external contract interactions are documented.
  • The critical configuration parameters are immutable once deployed, or the users have a documented path to opt out of the changes or exit the system if they are updated.

Documentation

“Documentation” refers to the presence of comprehensive and readable codebase documentation, including inline code comments, the roles and responsibilities of system entities, system invariants, use cases, expected system behavior, and data flow diagrams.

Weak

Minimal documentation is present, or documentation is clearly incomplete or outdated. Specific criteria include, but are not limited to, the following:

  • There is only a high-level description of the system.
  • Code comments do not match the documentation.
  • Documentation is not publicly available. (Note that this applies only to codebases meant for general public usage.)
  • Documentation depends directly on a set of artificial terms or words that are not clearly explained.

Moderate

The documentation adheres to best practices. Important components are documented; the documentation exists at different levels (such as inline code comments, NatSpec, and system documentation); and there is consistency across all documentation. Specific criteria include, but are not limited to, the following:

  • None of the weak criteria apply to the codebase.
  • Documentation is written in a clear manner, and the language is not ambiguous.
  • A glossary of terms exists for business-specific words and phrases.
  • The architecture is documented through diagrams or similar constructs.
  • Documentation includes user stories.
  • Documentation clearly identifies core/critical components, such as those that significantly affect the system and/or its users.
  • Reading documentation is sufficient to understand the expected behavior of the system without delving into specific implementation details.
  • All critical functions are documented.
  • All critical code blocks are documented.
  • Known risks and system limitations are documented.

Satisfactory

Thorough documentation exists spanning all of the areas required for a moderate rating, as well as system corner cases, detailed aspects of users stories, and all features. The documentation matches the code. Specific criteria include, but are not limited to, the following:

  • The system meets all moderate criteria.
  • The user stories cover all user operations.
  • There are detailed descriptions of the expected system behaviors.
  • The implementation is consistent with the specification; if there are deviations from the specification, they are strongly justified, thoroughly explained, and reasonable.
  • Function and system invariants are clearly defined in the documentation.
  • Consistent naming conventions are followed throughout the codebase and documentation.
  • There is specific documentation for end-users and for developers.

Transaction ordering risks

“Transaction ordering risks” refers to the resilience against malicious ordering of the transactions. This includes toxic forms of Miner Extractable Value (MEV), such as front-running, sandwiching, forced liquidations, and oracle attacks.

Weak

There are unexpected/undocumented risks that arise due to the ordering of transactions. Specific criteria include, but are not limited to, the following:

  • Transaction ordering risks are not clearly identified or documented.
  • Protocols or user assets are at risk of unexpected transaction ordering.
  • The system relies on unjustified constraints to prevent MEV extraction.
  • The system makes unproven assumptions about which attributes may or may not be manipulatable by an MEV extractor.

Moderate

Risks related to transaction ordering are identified and, when applicable, limited through on-chain mitigations. Specific criteria include, but are not limited to, the following:

  • None of the weak criteria apply to the codebase.
  • Transaction ordering risks related to user operations are limited, justified, and documented.
  • If MEV is inherent to the protocol, reasonable mitigations, such as time delays and slippage checks, are in place.
  • The testing strategy emphasizes transaction ordering risks.
  • The system uses tamper-resistant oracles.

Satisfactory

All transaction ordering risks are documented and clearly justified. The known risks are highlighted through documentation and tests and are visible to the users. Specific criteria include, but are not limited to, the following:

  • The system meets all moderate criteria.
  • The documentation centralizes all known MEV opportunities.
  • Transactions ordering risks on privileged operations (e.g., system updates) are limited, justified, and documented.
  • Known transaction ordering opportunities have tests highlighting the underlying risks.

Low-level manipulation

“Low-level manipulation” refers to the usage of low-level operations (e.g., assembly code, bitwise operations, low-level calls) and relevant justification within the codebase.

Weak

The code uses unjustified low-level manipulations. Specific criteria include, but are not limited to, the following:

  • Usage of assembly code or low-level manipulation is not justified; most can likely be replaced by high-level code.

Moderate

Low level operations are justified and limited. Extra documentation and testing is provided for them. Specific criteria include, but are not limited to, the following:

  • None of the weak criteria apply to the codebase.
  • Use of assembly code is limited and justified.
  • Inline code comments are present for each assembly operation.
  • The code does not re-implement well-established, low-level library functionality without justification (e.g., OZ’s SafeERC20).
  • A high-level implementation reference exists for each function with complex assembly code.

Satisfactory

Thorough documentation, justification, and testing exists to increase confidence in all usage of assembly code and low-level manipulation. Implementations are validated with automated testing against a reference implementation. Specific criteria include, but are not limited to, the following:

  • The system meets all moderate criteria.
  • Differential fuzzing, or a similar technique, is used to compare the high-level reference implementation against its low level counterpart.
  • Risks related to compiler optimization or experimental features are identified and justified.

Testing and verification

“Testing and verification” refers to the robustness of testing procedures of techniques (including unit tests, integration tests, fuzzing, and symbolic execution) as well as the amount of test coverage.

Weak

Testing is limited and covers only some of the “happy paths.” Specific criteria include, but are not limited to, the following:

  • Common or expected use cases are not fully tested.
  • Provided tests fail for the codebase.
  • There is insufficient or non-existent documentation to run the test suite “out of the box.”

Moderate

Testing adheres to best practices and covers a large majority of the code. An automated testing technique is used to increase the confidence of the most critical components. Specific criteria include, but are not limited to, the following:

  • None of the weak criteria apply to the codebase.
  • Most functions, including normal use cases, are tested.
  • All provided tests for the codebase pass.
  • Code coverage is used for the unit tests, and the report is easy to retrieve.
  • An automated testing technique is used for critical components.
  • Testing is implemented as part of the CI/CD pipeline.
  • Integration tests are implemented, if applicable.
  • Test code follows best practices and does not trigger warnings by the compiler or static analysis tools.

Satisfactory

Testing is clearly an important part of codebase development. Tests include unit tests and end-to-end testing. Code properties are clearly identified and validated with an automated testing technique. Specific criteria include, but are not limited to, the following:

  • The system meets all moderate criteria.
  • The codebase reaches 100% of reachable branch and statement coverage in unit tests.
  • An end-to-end automated testing technique is used and all users' entry points are covered.
  • Test cases are isolated and do not depend on each other or on the execution order, unless justified.
  • Mutant testing is used to detect missing or incorrect tests/properties.

Development Guidelines

Follow these high-level recommendations to build more secure smart contracts.

Design Guidelines

Discuss the design of the contract ahead of time, before writing any code.

Documentation and Specifications

Write documentation at different levels and update it as you implement the contracts:

  • A plain English description of the system, describing the contracts' purpose and any assumptions about the codebase.
  • Schema and architectural diagrams, including contract interactions and the system's state machine. Use Slither printers to help generate these schemas.
  • Thorough code documentation. Use the Natspec format for Solidity.

On-chain vs Off-chain Computation

  • Keep as much code off-chain as possible. Keep the on-chain layer small. Pre-process data off-chain in a way that simplifies on-chain verification. Need an ordered list? Sort it off-chain, then check its order on-chain.

Upgradeability

Refer to our blog post for different upgradeability solutions. If you are using delegatecall to achieve upgradability, carefully review all items of the delegatecall proxy guidance. Decide whether or not to support upgradeability before writing any code, as this decision will affect your code's structure. Generally, we recommend:

  • Favoring contract migration over upgradeability. Migration systems offer many of the same advantages as upgradeable systems but without their drawbacks.
  • Using the data separation pattern instead of the delegatecall proxy pattern. If your project has a clear abstraction separation, upgradeability using data separation will require only a few adjustments. The delegatecall proxy is highly error-prone and demands EVM expertise.
  • Document the migration/upgrade procedure before deployment. Write the procedure to follow ahead of time to avoid errors when reacting under stress. It should include:
    • The calls that initiate new contracts
    • The keys' storage location and access method
    • Deployment verification: develop and test a post-deployment script.

Delegatecall Proxy Pattern

The delegatecall opcode is a sharp tool that must be used carefully. Many high-profile exploits involve little-known edge cases and counter-intuitive aspects of the delegatecall proxy pattern. To aid the development of secure delegatecall proxies, utilize the slither-check-upgradability tool, which performs safety checks for both upgradable and immutable delegatecall proxies.

  • Storage layout: Proxy and implementation storage layouts must be the same. Instead of defining the same state variables for each contract, both should inherit all state variables from a shared base contract.
  • Inheritance: Be aware that the order of inheritance affects the final storage layout. For example, contract A is B, C and contract A is C, B will not have the same storage layout if both B and C define state variables.
  • Initialization: Immediately initialize the implementation. Well-known disasters (and near disasters) have featured an uninitialized implementation contract. A factory pattern can help ensure correct deployment and initialization and reduce front-running risks.
  • Function shadowing: If the same method is defined on the proxy and the implementation, then the proxy’s function will not be called. Be mindful of setOwner and other administration functions commonly found on proxies.
  • Direct implementation usage: Configure implementation state variables with values that prevent direct use, such as setting a flag during construction that disables the implementation and causes all methods to revert. This is particularly important if the implementation also performs delegatecall operations, as this may lead to unintended self-destruction of the implementation.
  • Immutable and constant variables: These variables are embedded in the bytecode and can get out of sync between the proxy and implementation. If the implementation has an incorrect immutable variable, this value may still be used even if the same variable is correctly set in the proxy’s bytecode.
  • Contract Existence Checks: All low-level calls, including delegatecall, return true for an address with empty bytecode. This can mislead callers into thinking a call performed a meaningful operation when it did not or cause crucial safety checks to be skipped. While a contract’s constructor runs, its bytecode remains empty until the end of execution. We recommend rigorously verifying that all low-level calls are protected against nonexistent contracts. Keep in mind that most proxy libraries (such as Openzeppelin's) do not automatically perform contract existence checks.

For more information on delegatecall proxies, consult our blog posts and presentations:

  • Contract Upgradability Anti-Patterns: Describes the differences between downstream data contracts and delegatecall proxies with upstream data contracts and how these patterns affect upgradability.
  • How the Diamond Standard Falls Short: Explores delegatecall risks that apply to all contracts, not just those following the diamond standard.
  • Breaking Aave Upgradeability: Discusses a subtle problem we discovered in Aave AToken contracts, resulting from the interplay between delegatecall proxies, contract existence checks, and unsafe initialization.
  • Contract Upgrade Risks and Recommendations: A Trail of Bits talk on best practices for developing upgradable delegatecall proxies. The section starting at 5:49 describes general risks for non-upgradable proxies.

Implementation Guidelines

Aim for simplicity. Use the simplest solution that meets your needs. Any member of your team should understand your solution.

Function Composition

Design your codebase architecture to facilitate easy review and allow testing individual components:

  • Divide the system's logic, either through multiple contracts or by grouping similar functions together (e.g. authentication, arithmetic).
  • Write small functions with clear purposes.

Inheritance

  • Keep inheritance manageable. Though inheritance can help divide logic you should aim to minimize the depth and width of the inheritance tree.
  • Use Slither’s inheritance printer to check contract hierarchies. The inheritance printer can help review the hierarchy size.

Events

  • Log all critical operations. Events facilitate contract debugging during development and monitoring after deployment.

Avoid Known Pitfalls

Dependencies

  • Use well-tested libraries. Importing code from well-tested libraries reduces the likelihood of writing buggy code. If writing an ERC20 contract, use OpenZeppelin.
  • Use a dependency manager instead of copying and pasting code. Always keep external sources up-to-date.

Testing and Verification

  • Create thorough unit tests. An extensive test suite is essential for developing high-quality software.
  • Develop custom Slither and Echidna checks and properties. Automated tools help ensure contract security. Review the rest of this guide to learn how to write efficient checks and properties.

Solidity

  • Favor Solidity versions outlined in our Slither Recommendations. We believe older Solidity versions are more secure and have better built-in practices. Newer versions may be too immature for production and need time to develop.
  • Compile using a stable release, but check for warnings with the latest release. Verify that the latest compiler version reports no issues with your code. However, since Solidity has a fast release cycle and a history of compiler bugs, we do not recommend the newest version for deployment. See Slither’s solc version recommendation.
  • Avoid inline assembly. Assembly requires EVM expertise. Do not write EVM code unless you have mastered the yellow paper.

Deployment Guidelines

After developing and deploying the contract:

  • Monitor contracts. Observe logs and be prepared to respond in the event of contract or wallet compromise.
  • Add contact information to blockchain-security-contacts. This list helps third parties notify you of discovered security flaws.
  • Secure privileged users' wallets. Follow our best practices for hardware wallet key storage.
  • Develop an incident response plan. Assume your smart contracts can be compromised. Possible threats include contract bugs or attackers gaining control of the contract owner's keys.

Token Integration Checklist

This checklist offers recommendations for interacting with arbitrary tokens. Ensure that every unchecked item is justified and that its risks are understood.

For convenience, all Slither utilities can be run directly on a token address, as shown below:

slither-check-erc 0xdac17f958d2ee523a2206206994597c13d831ec7 TetherToken --erc erc20
slither-check-erc 0x06012c8cf97BEaD5deAe237070F9587f8E7A266d KittyCore --erc erc721

Use the following Slither output for the token to follow this checklist:

- slither-check-erc [target] [contractName] [optional: --erc ERC_NUMBER]
- slither [target] --print human-summary
- slither [target] --print contract-summary
- slither-prop . --contract ContractName # requires configuration, and use of Echidna and Manticore

General Considerations

  • The contract has a security review. Avoid interacting with contracts that lack a security review. Assess the review's duration (i.e., the level of effort), the reputation of the security firm, and the number and severity of findings.
  • You have contacted the developers. If necessary, alert their team to incidents. Locate appropriate contacts on blockchain-security-contacts.
  • They have a security mailing list for critical announcements. Their team should advise users (like you!) on critical issues or when upgrades occur.

Contract Composition

  • The contract avoids unnecessary complexity. The token should be a simple contract; tokens with complex code require a higher standard of review. Use Slither’s human-summary printer to identify complex code.
  • The contract uses SafeMath. Contracts that do not use SafeMath require a higher standard of review. Inspect the contract manually for SafeMath usage.
  • The contract has only a few non-token-related functions. Non-token-related functions increase the likelihood of issues in the contract. Use Slither’s contract-summary printer to broadly review the code used in the contract.
  • The token has only one address. Tokens with multiple entry points for balance updates can break internal bookkeeping based on the address (e.g., balances[token_address][msg.sender] might not reflect the actual balance).

Owner Privileges

  • The token is not upgradeable. Upgradeable contracts may change their rules over time. Use Slither’s human-summary printer to determine if the contract is upgradeable.
  • The owner has limited minting capabilities. Malicious or compromised owners can abuse minting capabilities. Use Slither’s human-summary printer to review minting capabilities and consider manually reviewing the code.
  • The token is not pausable. Malicious or compromised owners can trap contracts relying on pausable tokens. Identify pausable code manually.
  • The owner cannot blacklist the contract. Malicious or compromised owners can trap contracts relying on tokens with a blacklist. Identify blacklisting features manually.
  • The team behind the token is known and can be held responsible for abuse. Contracts with anonymous development teams or teams situated in legal shelters require a higher standard of review.

ERC20 Tokens

ERC20 Conformity Checks

Slither includes the slither-check-erc utility that checks a token's conformance to various ERC standards. Use slither-check-erc to review the following:

  • Transfer and transferFrom return a boolean. Some tokens do not return a boolean for these functions, which may cause their calls in the contract to fail.
  • The name, decimals, and symbol functions are present if used. These functions are optional in the ERC20 standard and may not be present.
  • Decimals returns a uint8. Some tokens incorrectly return a uint256. In these cases, ensure the returned value is below 255.
  • The token mitigates the known ERC20 race condition. The ERC20 standard has a known race condition that must be mitigated to prevent attackers from stealing tokens.

Slither includes the slither-prop utility, which generates unit tests and security properties to find many common ERC flaws. Use slither-prop to review the following:

  • The contract passes all unit tests and security properties from slither-prop. Run the generated unit tests, then check the properties with Echidna and Manticore.

Risks of ERC20 Extensions

The behavior of certain contracts may differ from the original ERC specification. Review the following conditions manually:

  • The token is not an ERC777 token and has no external function call in transfer or transferFrom. External calls in the transfer functions can lead to reentrancies.
  • Transfer and transferFrom should not take a fee. Deflationary tokens can lead to unexpected behavior.
  • Consider any interest earned from the token. Some tokens distribute interest to token holders. If not taken into account, this interest may become trapped in the contract.

Token Scarcity

Token scarcity issues must be reviewed manually. Check for the following conditions:

  • The supply is owned by more than a few users. If a few users own most of the tokens, they can influence operations based on the tokens' distribution.
  • The total supply is sufficient. Tokens with a low total supply can be easily manipulated.
  • The tokens are located in more than a few exchanges. If all tokens are in one exchange, compromising the exchange could compromise the contract relying on the token.
  • Users understand the risks associated with large funds or flash loans. Contracts relying on the token balance must account for attackers with large funds or attacks executed through flash loans.
  • The token does not allow flash minting. Flash minting can lead to drastic changes in balance and total supply, requiring strict and comprehensive overflow checks in the token operation.

Known non-standard ERC20 tokens

Protocols that allow integration with arbitrary tokens must take care to properly handle certain well-known non-standard ERC20 tokens. Refer to the non-standard-tokens list for a list of well-known tokens that contain additional risks.

ERC721 Tokens

ERC721 Conformity Checks

The behavior of certain contracts may differ from the original ERC specification. Review the following conditions manually:

  • Transfers of tokens to the 0x0 address revert. Some tokens allow transfers to 0x0 and consider tokens sent to that address to have been burned; however, the ERC721 standard requires that such transfers revert.
  • safeTransferFrom functions are implemented with the correct signature. Some token contracts do not implement these functions. Transferring NFTs to one of those contracts can result in a loss of assets.
  • The name, decimals, and symbol functions are present if used. These functions are optional in the ERC721 standard and may not be present.
  • If used, decimals returns a uint8(0). Other values are invalid.
  • The name and symbol functions can return an empty string. This behavior is allowed by the standard.
  • The ownerOf function reverts if the tokenId is invalid or refers to a token that has already been burned. The function cannot return 0x0. This behavior is required by the standard but may not always be implemented correctly.
  • A transfer of an NFT clears its approvals. This is required by the standard.
  • The token ID of an NFT cannot be changed during its lifetime. This is required by the standard.

Common Risks of the ERC721 Standard

Mitigate the risks associated with ERC721 contracts by conducting a manual review of the following conditions:

  • The onERC721Received callback is taken into account. External calls in the transfer functions can lead to reentrancies, especially when the callback is not explicit (e.g., in safeMint calls).
  • When an NFT is minted, it is safely transferred to a smart contract. If a minting function exists, it should behave similarly to safeTransferFrom and handle the minting of new tokens to a smart contract properly, preventing asset loss.
  • Burning a token clears its approvals. If a burning function exists, it should clear the token’s previous approvals.

Known non-standard ERC20 tokens

The following tokens are known to be non-standard ERC20 tokens. They may have additional risks that must be covered.

Missing Revert

These tokens do not revert when a transfer fails, e.g. due to missing funds. Protocols that integrate these tokens must include a check for the transfer function's returned boolean success status and handle the failure case appropriately.

Transfer Hooks

These tokens include ERC777-like transfer hooks. Protocols that interact with tokens that include transfer hooks must be extra careful to protect against reentrant calls when dealing with these tokens, because control is handed back to the caller upon transfer. This can also affect cross-protocol reentrant calls to view functions.

Missing Return Data / Transfer Success Status

These tokens do not return any data from the external call when transferring tokens. Protocols using an interface that specifies a return value when transferring tokens will revert. Solidity includes automatic checks on the return data size when decoding return values of an expected size.

TokenNotes
Binance Coin (BNB)Only missing return data on transfer. transferFrom returns true.
OMGToken (OMG)
Tether USD (USDT)

Permit No-op

Does not revert when calling permit. Protocols that use EIP-2612 permits should check that the token allowance has increased or is sufficient. See Multichain's incident.

TokenNotes
Wrapped Ether (WETH)Includes a non-reverting fallback function.

Additional Non-standard Behavior

Additional non-standard token behavior that could be problematic includes:

  • fee on transfers
  • upgradeable contracts (USDC)
  • tokens with multiple address entry-points to the same accounting state
  • non-standard decimals (USDC: 6)
  • non-standard permits (DAI)
  • do not reduce allowance when it is the maximum value
  • do not require allowance for transfers from self
  • revert for approval of large amounts >= 2^96 < 2^256 - 1 (UNI, COMP)

Refer to d-xo/weird-erc20 for additional non-standard ERC20 tokens.

Incident Response Recommendations

How you respond during an incident is a direct reflection of your efforts to prepare for such an event. Each team or project's needs will vary so we provide the guidelines below as a starting point. Adherance to our guidelines can help you shift from a reactive approach to a proactive approach by planning with the assumption that incidents are inevitable. To fully leverage the following guidelines, consider them throughout the application development process.

Application Design

  • Identify which components should or should not be:
    • Pausable. While pausing a component can be beneficial during an incident, you must assess its potential impact on other contracts.
    • Migratable or upgradeable. Discovering a bug might necessitate a migration strategy or contract upgrade to fix the issue; note, however, that upgradeability has its own sets of risks. Making all contracts upgradeable might not be the best approach.
    • Decentralized. Using decentralized components can sometimes limit what rescue measures are possible and may require a higher amount of coordination.
  • Begin to identify important system invariants. This helps to determine what you will need to monitor and what events may be necessary to do so effectively.
  • Evaluate what additional events are needed. A missed event in a critical location might result in unnoticed incidents.
  • Evaluate what components must be on-chain and off-chain. On-chain components are generally more at risk, but off-chain components push the risks to the off-chain owner.
  • Use fine-grained access controls. Avoid setting all access controls to be available to an EOA. Opt for multisig wallets/MPC, and avoid delegating several roles to one address (e.g., the key responsible for setting fees shouldn't have access to the upgradeability feature).

Documentation

  • Assemble a runbook of common actions you may need to perform. It's not possible or practical to exhaustively detail how you'll respond to every type of incident. But you can start to document procedures for some of the more important ones as well as actions that might be common across multiple scenarios (e.g., pausing, rotating owner keys, upgrading an implementation). This can also include scripts or snippets of code to facilitate performing these actions in a reproducible manner.
  • Document how to interpret events emission. Only emitting events isn't sufficient; proper documentation is crucial, and users should be empowered to identify and decode them.
  • Document how to access wallets. Clearly outline how to access wallets with special roles in the system. This should include both the location as well as access procedures for each wallet.
  • Document the deployment and upgrade process. Deployment and upgrade processes are risky and must be thoroughly documented. This should include how to test the deployment/upgrade (e.g., using fork testing) and how to validate it (e.g., using a post-deployment script).
  • Document how to contact users and external dependencies. Define guidelines regarding which stakeholders to contact, including the timing and mode of communication in case of incidents. The right communication at the right time is key to maintaining trust.

Process

  • Conduct periodic training and incident response exercises. Regularly organize training sessions and incident response exercises. Such measures ensure that employees remain updated and can help highlight any flaws in the current incident response protocol.
  • Remember to consider off-chain components when planning. While much of this document is concerned with on-chain code, compromised frontends or social media accounts are also common sources of incidents.
  • Identify incident owners, with at least:
    • A technical lead. Responsible for gathering and centralizing technical data.
    • A communication lead. Tasked with internal and external communication.
    • A legal lead. Either provides legal advice or ensures the right legal entities are contacted. It might also be worth considering liaison with appropriate law enforcement agencies.
  • Use monitoring tools. You may opt for a third-party product, an in-house solution, or a combination of both. Third-party montoring will identify more generally suspicious transactions but may not be as in tune with system-specific metrics like health factors, collateralization ratios, or if an AMM invariant starts to drift. In-house monitoring, on the other hand, requires more engineering effort to setup and maintain, but can be tailored specifically to your needs.
  • Carefully consider automating certain actions based on monitoring alerts. You may wish to automatically pause or move the system into a safer state if certain actvities are detected given how quickly some exploits are carried out. However, also keep in mind the impact and likelihood of a false positive triggering such a mechanism and how disruptive that could be.

Threat Intelligence

  • Identify similar protocols, and stay informed of any issues affecting them. This could include forks, implementations on other chains, or protocols in the same general class (e.g., other lending protocols). Being aware of vulnerabilities in similar systems can help preemptively address potential threats in your own.
  • Identify your dependencies, and follow their communication channels to be alerted in case of an issue. Follow their Twitter, Discord, Telegram, newsletter, etc. This includes both on-chain as well as off-chain (e.g., libraries, toolchain) dependencies.
  • Maintain open communication lines with your dependencies' owners. This will help you to stay informed if one of your dependencies is compromised.
  • Subscribe to the BlockThreat newsletter. BlockThreat will keep you informed about recent incidents and developments in blockchain security. The nature of blockchains means we have a culture of learning in the open so take advantage of this and learn from your peers.

Additionally, consider conducting a threat modeling exercise. This exercise will identify risks that an application faces at both the structural and operational level. If you're interested in undertaking such an exercise and would like to work with us, contact us.

Resources

Community Incident Retrospectives

Secure Development Workflow

Follow this high-level process while developing your smart contracts for enhanced security:

  1. Check for known security issues:
  • Review your contracts using Slither, which has over 70 built-in detectors for common vulnerabilities. Run it on every check-in with new code and ensure it gets a clean report (or use triage mode to silence certain issues).
  1. Consider special features of your contract:
  • If your contracts are upgradeable, review your upgradeability code for flaws using slither-check-upgradeability or Crytic. We have documented 17 ways upgrades can go sideways.
  • If your contracts claim to conform to ERCs, check them with slither-check-erc. This tool instantly identifies deviations from six common specs.
  • If you have unit tests in Truffle, enrich them with slither-prop. It automatically generates a robust suite of security properties for features of ERC20 based on your specific code.
  • If you integrate with third-party tokens, review our token integration checklist before relying on external contracts.
  1. Visually inspect critical security features of your code:
  • Review Slither's inheritance-graph printer to avoid inadvertent shadowing and C3 linearization issues.
  • Review Slither's function-summary printer, which reports function visibility and access controls.
  • Review Slither's vars-and-auth printer, which reports access controls on state variables.
  1. Document critical security properties and use automated test generators to evaluate them:
  • Learn to document security properties for your code. Although challenging at first, it is the single most important activity for achieving a good outcome. It is also a prerequisite for using any advanced techniques in this tutorial.
  • Define security properties in Solidity for use with Echidna and Manticore. Focus on your state machine, access controls, arithmetic operations, external interactions, and standards conformance.
  • Define security properties with Slither's Python API. Concentrate on inheritance, variable dependencies, access controls, and other structural issues.
  1. Be mindful of issues that automated tools cannot easily find:
  • Lack of privacy: Transactions are visible to everyone else while queued in the pool.
  • Front running transactions.
  • Cryptographic operations.
  • Risky interactions with external DeFi components.

Ask for help

Office Hours are held every Tuesday afternoon. These one-hour, one-on-one sessions provide an opportunity to ask questions about security, troubleshoot tool usage, and receive expert feedback on your current approach. We will help you work through this guide.

Join our Slack: Empire Hacking. We are always available in the #crytic and #ethereum channels if you have questions.

Security is about more than just smart contracts

Review our quick tips for general application and corporate security. While it is crucial to ensure on-chain code security, off-chain security lapses can be equally severe, especially regarding owner keys.

How to prepare for a security review

Get ready for your security review! Ensuring a few key elements are in place before the review starts can make the process significantly smoother for both sides.

Set a goal for the review

This is the most important step of a security review, and paradoxically the one most often overlooked. You should have an idea of what kind of questions you want answered, such as:

  • What’s the overall level of security for this product?
  • What are the areas that you are the most concerns about?
    • Take into considerations previous audits and issues, complex parts, and fragile components.
  • What is the worst case scenario for your project?

Knowing your biggest area of concern will help the assessment team tailor their approach to meet your needs.

Resolve the easy issues

Handing the code off to the assessment team is a lot like releasing the product: the cleaner the code, the better everything will go. To that end:

  • Triage all results from static analysis tools. Go after the low-hanging fruits and use:
  • Increase unit and feature test coverage. Ideally this has been part of the development process, but everyone slips up, tests don’t get updated, or new features don’t quite match the old integrations tests. Now is the time to update the tests and run them all.
  • Remove dead code, unused libraries, and other extraneous weight. You may know which is unused but the consultants won’t and will waste time investigating it for potential issues. The same goes for that new feature that hasn’t seen progress in months, or that third-party library that doesn’t get used anymore.

Ensure the code is accessible

Making the code accessible and clearly identified will avoid wasting ressources from the security engineers.

  • Provide a detailed list of files for review.. This will avoid confusion if your codebase is large and some elements are not meant to be in scope.
  • Create a clear set of build instructions, and confirm the setup by cloning and testing your repository on a fresh environment. A code that cannot be built is a code more difficult to review.
  • Freeze a stable commit hash, branch, or release prior to review. Working on a moving target makes the review more difficult
  • Identify boilerplates, dependencies and difference from forked code. By highliting what code you wrote, you will help keeping the review focused

Document, Document, Document

Streamline the revuew process of building a mental model of your codebase by providing comprehensive documentation.

  • Create flowcharts and sequence diagrams to depict primary workflows. They will help identify the components and their relationships
  • Write users stories. Having users stories is a powerful tool to explain a project
  • Outline the on-chain / off-chain assumptions. This includes:
    • Data validation procedure
    • Oracles information
    • Bridges assumptions
  • List actors and with their respective roles and privileges. The complexity of a system grows with its number of actors.
  • Incorporate external developer documentation that links directly to your code. This will help to ensure the documentation is up to date with the code
  • Add function documentation and inline comments for complex areas of your system. Code documentation should include:
    • System and function level invariants
    • Parameter ranges (minimum and maximum values) used in your system.
    • Arithmetic formula: how they map to their specification, and their precision loss exceptations
  • Compile a glossary for consistent terminology use. You use your codebase every day and you are familar with the terminology - a new person looking at your code is not
  • Consider creating short video walkthroughs for complex workflows or areas of concern. Video walkthroughs is a great format to share your knowledge

Learn EVM

List of EVM Technical Knowledge

Ethereum VM (EVM) Opcodes and Instruction Reference

This reference consolidates EVM opcode information from the yellow paper, stack exchange, solidity source, parity source, evm-opcode-gas-costs and Manticore.

Notes

The size of a "word" in EVM is 256 bits.

The gas information is a work in progress. If an asterisk is in the Gas column, the base cost is shown but may vary based on the opcode arguments.

Table

OpcodeNameDescriptionExtra InfoGas
0x00STOPHalts execution-0
0x01ADDAddition operation-3
0x02MULMultiplication operation-5
0x03SUBSubtraction operation-3
0x04DIVInteger division operation-5
0x05SDIVSigned integer division operation (truncated)-5
0x06MODModulo remainder operation-5
0x07SMODSigned modulo remainder operation-5
0x08ADDMODModulo addition operation-8
0x09MULMODModulo multiplication operation-8
0x0aEXPExponential operation-10*
0x0bSIGNEXTENDExtend length of two's complement signed integer-5
0x0c - 0x0fUnusedUnused-
0x10LTLess-than comparison-3
0x11GTGreater-than comparison-3
0x12SLTSigned less-than comparison-3
0x13SGTSigned greater-than comparison-3
0x14EQEquality comparison-3
0x15ISZEROSimple not operator-3
0x16ANDBitwise AND operation-3
0x17ORBitwise OR operation-3
0x18XORBitwise XOR operation-3
0x19NOTBitwise NOT operation-3
0x1aBYTERetrieve single byte from word-3
0x1bSHLShift LeftEIP1453
0x1cSHRLogical Shift RightEIP1453
0x1dSARArithmetic Shift RightEIP1453
0x20KECCAK256Compute Keccak-256 hash-30*
0x21 - 0x2fUnusedUnused
0x30ADDRESSGet address of currently executing account-2
0x31BALANCEGet balance of the given account-700
0x32ORIGINGet execution origination address-2
0x33CALLERGet caller address-2
0x34CALLVALUEGet deposited value by the instruction/transaction responsible for this execution-2
0x35CALLDATALOADGet input data of current environment-3
0x36CALLDATASIZEGet size of input data in current environment-2*
0x37CALLDATACOPYCopy input data in current environment to memory-3
0x38CODESIZEGet size of code running in current environment-2
0x39CODECOPYCopy code running in current environment to memory-3*
0x3aGASPRICEGet price of gas in current environment-2
0x3bEXTCODESIZEGet size of an account's code-700
0x3cEXTCODECOPYCopy an account's code to memory-700*
0x3dRETURNDATASIZEPushes the size of the return data buffer onto the stackEIP 2112
0x3eRETURNDATACOPYCopies data from the return data buffer to memoryEIP 2113
0x3fEXTCODEHASHReturns the keccak256 hash of a contract's codeEIP 1052700
0x40BLOCKHASHGet the hash of one of the 256 most recent complete blocks-20
0x41COINBASEGet the block's beneficiary address-2
0x42TIMESTAMPGet the block's timestamp-2
0x43NUMBERGet the block's number-2
0x44DIFFICULTYGet the block's difficulty-2
0x45GASLIMITGet the block's gas limit-2
0x46CHAINIDReturns the current chain’s EIP-155 unique identifierEIP 13442
0x47 - 0x4fUnused-
0x48BASEFEEReturns the value of the base fee of the current block it is executing in.EIP 31982
0x50POPRemove word from stack-2
0x51MLOADLoad word from memory-3*
0x52MSTORESave word to memory-3*
0x53MSTORE8Save byte to memory-3
0x54SLOADLoad word from storage-800
0x55SSTORESave word to storage-20000**
0x56JUMPAlter the program counter-8
0x57JUMPIConditionally alter the program counter-10
0x58PCGet the value of the program counter prior to the increment-2
0x59MSIZEGet the size of active memory in bytes-2
0x5aGASGet the amount of available gas, including the corresponding reduction for the cost of this instruction-2
0x5bJUMPDESTMark a valid destination for jumps-1
0x5c - 0x5eUnused-
0x5fPUSH0Place the constant value 0 on stackEIP-38552
0x60PUSH1Place 1 byte item on stack-3
0x61PUSH2Place 2-byte item on stack-3
0x62PUSH3Place 3-byte item on stack-3
0x63PUSH4Place 4-byte item on stack-3
0x64PUSH5Place 5-byte item on stack-3
0x65PUSH6Place 6-byte item on stack-3
0x66PUSH7Place 7-byte item on stack-3
0x67PUSH8Place 8-byte item on stack-3
0x68PUSH9Place 9-byte item on stack-3
0x69PUSH10Place 10-byte item on stack-3
0x6aPUSH11Place 11-byte item on stack-3
0x6bPUSH12Place 12-byte item on stack-3
0x6cPUSH13Place 13-byte item on stack-3
0x6dPUSH14Place 14-byte item on stack-3
0x6ePUSH15Place 15-byte item on stack-3
0x6fPUSH16Place 16-byte item on stack-3
0x70PUSH17Place 17-byte item on stack-3
0x71PUSH18Place 18-byte item on stack-3
0x72PUSH19Place 19-byte item on stack-3
0x73PUSH20Place 20-byte item on stack-3
0x74PUSH21Place 21-byte item on stack-3
0x75PUSH22Place 22-byte item on stack-3
0x76PUSH23Place 23-byte item on stack-3
0x77PUSH24Place 24-byte item on stack-3
0x78PUSH25Place 25-byte item on stack-3
0x79PUSH26Place 26-byte item on stack-3
0x7aPUSH27Place 27-byte item on stack-3
0x7bPUSH28Place 28-byte item on stack-3
0x7cPUSH29Place 29-byte item on stack-3
0x7dPUSH30Place 30-byte item on stack-3
0x7ePUSH31Place 31-byte item on stack-3
0x7fPUSH32Place 32-byte (full word) item on stack-3
0x80DUP1Duplicate 1st stack item-3
0x81DUP2Duplicate 2nd stack item-3
0x82DUP3Duplicate 3rd stack item-3
0x83DUP4Duplicate 4th stack item-3
0x84DUP5Duplicate 5th stack item-3
0x85DUP6Duplicate 6th stack item-3
0x86DUP7Duplicate 7th stack item-3
0x87DUP8Duplicate 8th stack item-3
0x88DUP9Duplicate 9th stack item-3
0x89DUP10Duplicate 10th stack item-3
0x8aDUP11Duplicate 11th stack item-3
0x8bDUP12Duplicate 12th stack item-3
0x8cDUP13Duplicate 13th stack item-3
0x8dDUP14Duplicate 14th stack item-3
0x8eDUP15Duplicate 15th stack item-3
0x8fDUP16Duplicate 16th stack item-3
0x90SWAP1Exchange 1st and 2nd stack items-3
0x91SWAP2Exchange 1st and 3rd stack items-3
0x92SWAP3Exchange 1st and 4th stack items-3
0x93SWAP4Exchange 1st and 5th stack items-3
0x94SWAP5Exchange 1st and 6th stack items-3
0x95SWAP6Exchange 1st and 7th stack items-3
0x96SWAP7Exchange 1st and 8th stack items-3
0x97SWAP8Exchange 1st and 9th stack items-3
0x98SWAP9Exchange 1st and 10th stack items-3
0x99SWAP10Exchange 1st and 11th stack items-3
0x9aSWAP11Exchange 1st and 12th stack items-3
0x9bSWAP12Exchange 1st and 13th stack items-3
0x9cSWAP13Exchange 1st and 14th stack items-3
0x9dSWAP14Exchange 1st and 15th stack items-3
0x9eSWAP15Exchange 1st and 16th stack items-3
0x9fSWAP16Exchange 1st and 17th stack items-3
0xa0LOG0Append log record with no topics-375
0xa1LOG1Append log record with one topic-750
0xa2LOG2Append log record with two topics-1125
0xa3LOG3Append log record with three topics-1500
0xa4LOG4Append log record with four topics-1875
0xa5 - 0xafUnused-
0xb0JUMPTOTentative libevmasm has different numbersEIP 615
0xb1JUMPIFTentativeEIP 615
0xb2JUMPSUBTentativeEIP 615
0xb4JUMPSUBVTentativeEIP 615
0xb5BEGINSUBTentativeEIP 615
0xb6BEGINDATATentativeEIP 615
0xb8RETURNSUBTentativeEIP 615
0xb9PUTLOCALTentativeEIP 615
0xbaGETLOCALTentativeEIP 615
0xbb - 0xe0Unused-
0xe1SLOADBYTESOnly referenced in pyethereum--
0xe2SSTOREBYTESOnly referenced in pyethereum--
0xe3SSIZEOnly referenced in pyethereum--
0xe4 - 0xefUnused-
0xf0CREATECreate a new account with associated code-32000
0xf1CALLMessage-call into an account-Complicated
0xf2CALLCODEMessage-call into this account with alternative account's code-Complicated
0xf3RETURNHalt execution returning output data-0
0xf4DELEGATECALLMessage-call into this account with an alternative account's code, but persisting into this account with an alternative account's code-Complicated
0xf5CREATE2Create a new account and set creation address to sha3(sender + sha3(init code)) % 2**160-
0xf6 - 0xf9Unused--
0xfaSTATICCALLSimilar to CALL, but does not modify state-40
0xfbUnused--
0xfdREVERTStop execution and revert state changes, without consuming all provided gas and providing a reason-0
0xfeINVALIDDesignated invalid instruction-0
0xffSELFDESTRUCTHalt execution and register account for later deletion-5000*

Instruction Details


STOP

0x00

() => ()

halts execution


ADD

0x01

Takes two words from stack, adds them, then pushes the result onto the stack.

(a, b) => (c)

c = a + b


MUL

0x02

(a, b) => (c)

c = a * b


SUB

0x03

(a, b) => (c)

c = a - b


DIV

0x04

(a, b) => (c)

c = a / b


SDIV

0x05

(a: int256, b: int256) => (c: int256)

c = a / b


MOD

0x06

(a, b) => (c)

c = a % b


SMOD

0x07

(a: int256, b: int256) => (c: int256)

c = a % b


ADDMOD

0x08

(a, b, m) => (c)

c = (a + b) % m


MULMOD

0x09

(a, b, m) => (c)

c = (a * b) % m


EXP

0x0a

(a, b, m) => (c)

c = (a * b) % m


SIGNEXTEND

0x0b

(b, x) => (y)

y = SIGNEXTEND(x, b)

sign extends x from (b + 1) * 8 bits to 256 bits.


LT

0x10

(a, b) => (c)

c = a < b

all values interpreted as uint256


GT

0x11

(a, b) => (c)

c = a > b

all values interpreted as uint256


SLT

0x12

(a, b) => (c)

c = a < b

all values interpreted as int256


SGT

0x13

(a, b) => (c)

c = a > b

all values interpreted as int256


EQ

0x14

Pops 2 elements off the stack and pushes the value 1 to the stack in case they're equal, otherwise the value 0.

(a, b) => (c)

c = a == b


ISZERO

0x15

(a) => (c)

c = a == 0


AND

0x16

(a, b) => (c)

c = a & b


OR

0x17

(a, b) => (c)

c = a | b


XOR

0x18

(a, b) => (c)

c = a ^ b


NOT

0x19

(a) => (c)

c = ~a


BYTE

0x1a

(i, x) => (y)

y = (x >> (248 - i * 8) & 0xff


SHL

0x1b

Pops 2 elements from the stack and pushes the second element onto the stack shifted left by the shift amount (first element).

(shift, value) => (res)

res = value << shift


SHR

0x1c

Pops 2 elements from the stack and pushes the second element onto the stack shifted right by the shift amount (first element).

(shift, value) => (res)

res = value >> shift


SAR

0x1d

(shift, value) => (res)

res = value >> shift

value: int256


KECCAK256

0x20

(offset, len) => (hash)

hash = keccak256(memory[offset:offset+len])


ADDRESS

0x30

() => (address(this))


BALANCE

0x31

() => (address(this).balance)


ORIGIN

0x32

() => (tx.origin)


CALLER

0x33

() => (msg.sender)


CALLVALUE

0x34

() => (msg.value)


CALLDATALOAD

0x35

(index) => (msg.data[index:index+32])


CALLDATASIZE

0x36

() => (msg.data.size)


CALLDATACOPY

0x37

(memOffset, offset, length) => ()

memory[memOffset:memOffset+len] = msg.data[offset:offset+len]


CODESIZE

0x38

() => (address(this).code.size)


CODECOPY

0x39

(memOffset, codeOffset, len) => ()

memory[memOffset:memOffset+len] = address(this).code[codeOffset:codeOffset+len]


GASPRICE

0x3a

() => (tx.gasprice)


EXTCODESIZE

0x3b

(addr) => (address(addr).code.size)


EXTCODECOPY

0x3c

(addr, memOffset, offset, length) => ()

memory[memOffset:memOffset+len] = address(addr).code[codeOffset:codeOffset+len]


RETURNDATASIZE

0x3d

() => (size)

size = RETURNDATASIZE()

The number of bytes that were returned from the last ext call


RETURNDATACOPY

0x3e

(memOffset, offset, length) => ()

memory[memOffset:memOffset+len] = RETURNDATA[codeOffset:codeOffset+len]

RETURNDATA is the data returned from the last external call


EXTCODEHASH

0x3f

(addr) => (hash)

hash = address(addr).exists ? keccak256(address(addr).code) : 0


BLOCKHASH

0x40

(number) => (hash)

hash = block.blockHash(number)


COINBASE

0x41

() => (block.coinbase)


TIMESTAMP

0x42

() => (block.timestamp)


NUMBER

0x43

() => (block.number)


DIFFICULTY

0x44

() => (block.difficulty)


GASLIMIT

0x45

() => (block.gaslimit)


CHAINID

0x46

() => (chainid)

where chainid = 1 for mainnet & some other value for other networks


SELFBALANCE

0x47

() => (address(this).balance)


BASEFEE

0x48

() => (block.basefee)

current block's base fee (related to EIP1559)


POP

0x50

(a) => ()

discards the top stack item


MLOAD

0x51

(offset) => (value)

value = memory[offset:offset+32]


MSTORE

0x52

Saves a word to the EVM memory. Pops 2 elements from stack - the first element being the word memory address where the saved value (second element popped from stack) will be stored.

(offset, value) => ()

memory[offset:offset+32] = value


MSTORE8

0x53

(offset, value) => ()

memory[offset:offset+32] = value & 0xff


SLOAD

0x54

Pops 1 element off the stack, that being the key which is the storage slot and returns the read value stored there.

(key) => (value)

value = storage[key]


SSTORE

0x55

Pops 2 elements off the stack, the first element being the key and the second being the value which is then stored at the storage slot represented from the first element (key).

(key, value) => ()

storage[key] = value


JUMP

0x56

(dest) => ()

pc = dest


JUMPI

0x57

Conditional - Pops 2 elements from the stack, the first element being the jump location and the second being the value 0 (false) or 1 (true). If the value’s 1 the PC will be altered and the jump executed. Otherwise, the value will be 0 and the PC will remain the same and execution unaltered.

(dest, cond) => ()

pc = cond ? dest : pc + 1


PC

0x58

() => (pc)


MSIZE

0x59

() => (memory.size)


GAS

0x5a

() => (gasRemaining)

not including the gas required for this opcode


JUMPDEST

0x5b

() => ()

noop, marks a valid jump destination


PUSH0

0x5f

The constant value 0 is pushed onto the stack.

() => (0)


PUSH1

0x60

The following byte is read from PC, placed into a word, then this word is pushed onto the stack.

() => (address(this).code[pc+1:pc+2])


PUSH2

0x61

() => (address(this).code[pc+2:pc+3])


PUSH3

0x62

() => (address(this).code[pc+3:pc+4])


PUSH4

0x63

() => (address(this).code[pc+4:pc+5])


PUSH5

0x64

() => (address(this).code[pc+5:pc+6])


PUSH6

0x65

() => (address(this).code[pc+6:pc+7])


PUSH7

0x66

() => (address(this).code[pc+7:pc+8])


PUSH8

0x67

() => (address(this).code[pc+8:pc+9])


PUSH9

0x68

() => (address(this).code[pc+9:pc+10])


PUSH10

0x69

() => (address(this).code[pc+10:pc+11])


PUSH11

0x6a

() => (address(this).code[pc+11:pc+12])


PUSH12

0x6b

() => (address(this).code[pc+12:pc+13])


PUSH13

0x6c

() => (address(this).code[pc+13:pc+14])


PUSH14

0x6d

() => (address(this).code[pc+14:pc+15])


PUSH15

0x6e

() => (address(this).code[pc+15:pc+16])


PUSH16

0x6f

() => (address(this).code[pc+16:pc+17])


PUSH17

0x70

() => (address(this).code[pc+17:pc+18])


PUSH18

0x71

() => (address(this).code[pc+18:pc+19])


PUSH19

0x72

() => (address(this).code[pc+19:pc+20])


PUSH20

0x73

() => (address(this).code[pc+20:pc+21])


PUSH21

0x74

() => (address(this).code[pc+21:pc+22])


PUSH22

0x75

() => (address(this).code[pc+22:pc+23])


PUSH23

0x76

() => (address(this).code[pc+23:pc+24])


PUSH24

0x77

() => (address(this).code[pc+24:pc+25])


PUSH25

0x78

() => (address(this).code[pc+25:pc+26])


PUSH26

0x79

() => (address(this).code[pc+26:pc+27])


PUSH27

0x7a

() => (address(this).code[pc+27:pc+28])


PUSH28

0x7b

() => (address(this).code[pc+28:pc+29])


PUSH29

0x7c

() => (address(this).code[pc+29:pc+30])


PUSH30

0x7d

() => (address(this).code[pc+30:pc+31])


PUSH31

0x7e

() => (address(this).code[pc+31:pc+32])


PUSH32

0x7f

() => (address(this).code[pc+32:pc+33])


DUP1

0x80

(1) => (1, 1)


DUP2

0x81

(1, 2) => (2, 1, 2)


DUP3

0x82

(1, 2, 3) => (3, 1, 2, 3)


DUP4

0x83

(1, ..., 4) => (4, 1, ..., 4)


DUP5

0x84

(1, ..., 5) => (5, 1, ..., 5)


DUP6

0x85

(1, ..., 6) => (6, 1, ..., 6)


DUP7

0x86

(1, ..., 7) => (7, 1, ..., 7)


DUP8

0x87

(1, ..., 8) => (8, 1, ..., 8)


DUP9

0x88

(1, ..., 9) => (9, 1, ..., 9)


DUP10

0x89

(1, ..., 10) => (10, 1, ..., 10)


DUP11

0x8a

(1, ..., 11) => (11, 1, ..., 11)


DUP12

0x8b

(1, ..., 12) => (12, 1, ..., 12)


DUP13

0x8c

(1, ..., 13) => (13, 1, ..., 13)


DUP14

0x8d

(1, ..., 14) => (14, 1, ..., 14)


DUP15

0x8e

(1, ..., 15) => (15, 1, ..., 15)


DUP16

0x8f

(1, ..., 16) => (16, 1, ..., 16)


SWAP1

0x90

(1, 2) => (2, 1)


SWAP2

0x91

(1, 2, 3) => (3, 2, 1)


SWAP3

0x92

(1, ..., 4) => (4, ..., 1)


SWAP4

0x93

(1, ..., 5) => (5, ..., 1)


SWAP5

0x94

(1, ..., 6) => (6, ..., 1)


SWAP6

0x95

(1, ..., 7) => (7, ..., 1)


SWAP7

0x96

(1, ..., 8) => (8, ..., 1)


SWAP8

0x97

(1, ..., 9) => (9, ..., 1)


SWAP9

0x98

(1, ..., 10) => (10, ..., 1)


SWAP10

0x99

(1, ..., 11) => (11, ..., 1)


SWAP11

0x9a

(1, ..., 12) => (12, ..., 1)


SWAP12

0x9b

(1, ..., 13) => (13, ..., 1)


SWAP13

0x9c

(1, ..., 14) => (14, ..., 1)


SWAP14

0x9d

(1, ..., 15) => (15, ..., 1)


SWAP15

0x9e

(1, ..., 16) => (16, ..., 1)


SWAP16

0x9f

(1, ..., 17) => (17, ..., 1)


LOG0

0xa0

(offset, length) => ()

emit(memory[offset:offset+length])


LOG1

0xa1

(offset, length, topic0) => ()

emit(memory[offset:offset+length], topic0)


LOG2

0xa2

(offset, length, topic0, topic1) => ()

emit(memory[offset:offset+length], topic0, topic1)


LOG3

0xa3

(offset, length, topic0, topic1, topic2) => ()

emit(memory[offset:offset+length], topic0, topic1, topic2)


LOG4

0xa4

(offset, length, topic0, topic1, topic2, topic3) => ()

emit(memory[offset:offset+length], topic0, topic1, topic2, topic3)


CREATE

0xf0

(value, offset, length) => (addr)

addr = keccak256(rlp([address(this), this.nonce]))[12:] addr.code = exec(memory[offset:offset+length]) addr.balance += value this.balance -= value this.nonce += 1


CALL

0xf1

(gas, addr, value, argsOffset, argsLength, retOffset, retLength) => (success)

memory[retOffset:retOffset+retLength] = address(addr).callcode.gas(gas).value(value)(memory[argsOffset:argsOffset+argsLength]) success = true (unless the prev call reverted)


CALLCODE

0xf2

(gas, addr, value, argsOffset, argsLength, retOffset, retLength) => (success)

memory[retOffset:retOffset+retLength] = address(addr).callcode.gas(gas).value(value)(memory[argsOffset:argsOffset+argsLength]) success = true (unless the prev call reverted)

TODO: what's the difference between this & CALL?


RETURN

0xf3

(offset, length) => ()

return memory[offset:offset+length]


DELEGATECALL

0xf4

(gas, addr, argsOffset, argsLength, retOffset, retLength) => (success)

memory[retOffset:retOffset+retLength] = address(addr).delegatecall.gas(gas)(memory[argsOffset:argsOffset+argsLength]) success = true (unless the prev call reverted)


CREATE2

0xf5

(value, offset, length, salt) => (addr)

initCode = memory[offset:offset+length] addr = keccak256(0xff ++ address(this) ++ salt ++ keccak256(initCode))[12:] address(addr).code = exec(initCode)


STATICCALL

0xfa

(gas, addr, argsOffset, argsLength, retOffset, retLength) => (success)

memory[retOffset:retOffset+retLength] = address(addr).delegatecall.gas(gas)(memory[argsOffset:argsOffset+argsLength]) success = true (unless the prev call reverted)

TODO: what's the difference between this & DELEGATECALL?


REVERT

0xfd

(offset, length) => ()

revert(memory[offset:offset+length])


SELFDESTRUCT

0xff

(addr) => ()

address(addr).send(address(this).balance) this.code = 0

Tracing Utils

Transaction Tracing

One excellent way to learn more about the internal workings of the EVM is to trace the execution of a transaction opcode by opcode. This approach can also help you assess the correctness of assembly code and catch problems related to the compiler or its optimization steps.

The following JavaScript snippet uses an ethers provider to connect to an Ethereum node with the debug JSON RPC endpoints activated. Although this requires an archive node on Mainnet, it can also be run quickly and easily against a local development Testnet using Hardhat node, Ganache, or some other Ethprovider targeting developers.

Transaction traces for even simple smart contract interactions are verbose, so we recommend providing a filename to save the trace for further analysis. Note that the following function depends on the fs module built into Node.js, so it should be copied into a Node console rather than a browser console. However, the filesystem interactions could be removed for use in the browser.

const ethers = require("ethers");
const fs = require("fs");

const provider = new ethers.providers.JsonRpcProvider(
  process.env.ETH_PROVIDER || "http://localhost:8545"
);

let traceTx = async (txHash, filename) => {
  await provider.send("debug_traceTransaction", [txHash]).then((res) => {
    console.log(`Got a response with keys: ${Object.keys(res)}`);
    const indexedRes = {
      ...res,
      structLogs: res.structLogs.map((structLog, index) => ({
        index,
        ...structLog,
      })),
    };
    if (filename) {
      fs.writeFileSync(filename, JSON.stringify(indexedRes, null, 2));
    } else {
      log(indexedRes);
    }
  });
};

By default, transaction traces do not feature a sequential index, making it difficult to answer questions such as, "Which was the 100th opcode executed?" The above script adds such an index for easier navigation and communication.

The output of the script contains a list of opcode executions. A snippet might look something like:

{
  "structLogs": [
    ...,
    {
      "index": 191,
      "pc": 3645,
      "op": "SSTORE",
      "gas": 10125,
      "gasCost": 2900,
      "depth": 1,
      "stack": [
        "0xa9059cbb",
        "0x700",
        "0x7fb610713c8404e21676c01c271bb662df6eb63c",
        "0x1d8b64f4775be40000",
        "0x0",
        "0x1e01",
        "0x68e224065325c640131672779181a2f2d1324c4d",
        "0x7fb610713c8404e21676c01c271bb662df6eb63c",
        "0x1d8b64f4775be40000",
        "0x0",
        "0x14af3e50252dfc40000",
        "0x14af3e50252dfc40000",
        "0x7d7d4dc7c32ad4c905ab39fc25c4323c4a85e4b1b17a396514e6b88ee8b814e8"
      ],
      "memory": [
        "00000000000000000000000068e224065325c640131672779181a2f2d1324c4d",
        "0000000000000000000000000000000000000000000000000000000000000002",
        "0000000000000000000000000000000000000000000000000000000000000080"
      ],
      "storage": {
        "7d7d4dc7c32ad4c905ab39fc25c4323c4a85e4b1b17a396514e6b88ee8b814e8": "00000000000000000000000000000000000000000000014af3e50252dfc40000"
      }
    },
    ...,
  ],
  "gas": 34718,
  "failed": false,
  "returnValue": "0000000000000000000000000000000000000000000000000000000000000001"
}

An overview of the fields for opcode execution trace:

  • index: The index we added indicates that the above opcode was the 191st one executed. This is helpful for staying oriented as you jump around the trace.
  • pc: Program counter, for example, this opcode exists at index 3645 of the contract bytecode. You will notice that pc increments by one for many common opcodes, by more than one for PUSH opcodes, and is reset entirely by JUMP/JUMP opcodes.
  • op: Name of the opcode. Since most of the actual data is hex-encoded, using grep or ctrl-f to search through the trace for opcode names is an effective strategy.
  • gas: Remaining gas before the opcode is executed
  • gasCost: Cost of this operation. For CALL and similar opcodes, this cost takes into account all gas spent by the child execution frame.
  • depth: Each call creates a new child execution frame, and this variable tracks how many sub-frames exist. Generally, CALL opcodes increase the depth and RETURN opcodes decrease it.
  • stack: A snapshot of the entire stack before the opcode executes
  • memory: A snapshot of the entire memory before the opcode executes
  • storage: An accumulation of all state changes made during the execution of the transaction being traced

Navigating a transaction trace can be challenging, especially when trying to match opcode executions to higher-level Solidity code. An effective first step is to identify uncommon opcodes that correspond to easily identifiable logic in the source code. Generally, expensive operations are relatively uncommon, so SLOAD and SSTORE are good ones to scan first and match against places where state variables are read or written in Solidity. Alternatively, CALL and related opcodes are relatively uncommon and can be matched with calls to other contracts in the source code.

If there is a specific part of the source code you are interested in tracing, matching uncommon opcodes to the source code will give you bounds on where to search. From this point, you will likely start walking through the trace opcode by opcode as you review the source code line by line. Leaving a few ephemeral comments in the source code, like # opcode 191, can help you keep track and pick up where you left off if you need to take a break.

Exploring transaction traces is challenging work, but the reward is an ultra-high-definition view of how the EVM operates internally and can help you identify problems that might not be apparent from just the source code.

Storage Tracing

Although you can get an overview of all the changes to the contract state by checking the storage field of the last executed opcode in the above trace, the following helper function will extract that for you for quicker and easier analysis. If you are conducting a more involved investigation into a contract's state, we recommend you check out the slither-read-storage command for a more powerful tool.

const traceStorage = async (txHash) => {
  await provider.send("debug_traceTransaction", [txHash]).then((res) => {
    log(res.structLogs[res.structLogs.length - 1].storage);
  });
};

A Guide on Performing Arithmetic Checks in the EVM

The Ethereum Virtual Machine (EVM) distinguishes itself from other virtual machines and computer systems through several unique aspects. One notable difference is its treatment of arithmetic checks. While most architectures and virtual machines provide access to carry bits or an overflow flag, these features are absent in the EVM. Consequently, these safeguards must be incorporated within the machine's constraints.

Starting with Solidity version 0.8.0 the compiler automatically includes over and underflow protection in all arithmetic operations. Prior to version 0.8.0, developers were required to implement these checks manually, often using a library known as SafeMath, originally developed by OpenZeppelin. The compiler incorporates arithmetic checks in a manner similar to SafeMath, through additional operations.

As the Solidity language has evolved, the compiler has generated increasingly optimized code for arithmetic checks. This trend is also observed in smart contract development in general, where highly optimized arithmetic code written in low-level assembly is becoming more common. However, there is still a lack of comprehensive resources explaining the nuances of how the EVM handles arithmetic for signed and unsigned integers of 256 bits and less.

This article serves as a guide for gaining a deeper understanding of arithmetic in the EVM by exploring various ways to perform arithmetic checks. We'll learn more about the two's complement system and some lesser-known opcodes. This article is designed for those curious about the EVM's inner workings and those interested in bit manipulations in general. A basic understanding of bitwise arithmetic and Solidity opcodes is assumed.

Additional references for complementary reading are:

Disclaimer: Please note that this article is for educational purposes. It is not our intention to encourage micro optimizations in order to save gas, as this can potentially introduce new, hard-to-detect bugs that may compromise the security and stability of a protocol. As a developer, prioritize the safety and security of the protocol over premature optimizations. Including redundant checks for critical operations may be a good practice when the protocol code is still evolving. However, we do encourage experimentation with these operations for educational purposes.

Arithmetic checks for uint256 addition

To examine how the solc compiler implements arithmetic checks, we can compile the code with the --asm flag and inspect the resulting bytecode. Alternatively, using the --ir flag allows us to examine the Yul code that is generated as an intermediate representation (IR).

Note that Solidity aims to make the new Yul pipeline the standard. Certain operations (including arithmetic checks) are always included as Yul code, regardless of whether the code is compiled with the new pipeline using --via-ir. This provides an opportunity to examine the Yul code and gain a better understanding of how arithmetic checks are executed in Solidity. However, keep in mind that the final bytecode may differ slightly when compiler optimizations are turned on.

To illustrate how the compiler detects overflow in unsigned integer addition, consider the following example of Yul code produced by the compiler before version 0.8.16.

function checked_add_t_uint256(x, y) -> sum {
    x := cleanup_t_uint256(x)
    y := cleanup_t_uint256(y)

    // overflow, if x > (maxValue - y)
    if gt(x, sub(0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff, y)) { panic_error_0x11() }

    sum := add(x, y)
}

To improve readability, we can translate the Yul code back into high-level Solidity code.

/// @notice versions >=0.8.0 && <0.8.16
function checkedAddUint1(uint256 a, uint256 b) public pure returns (uint256 c) {
    unchecked {
        c = a + b;

        if (a > type(uint256).max - b) arithmeticError();
    }
}

Solidity's arithmetic errors are encoded as abi.encodeWithSignature("Panic(uint256)", 0x11).

The check for overflow in unsigned integer addition involves calculating the largest value that one summand can have when added to the other without causing an overflow. Specifically, in this case, the maximum value a can have is type(uint256).max - b. If a exceeds this value, we can conclude that a + b will overflow.

An alternative and slightly more efficient approach for computing the maximum value of a involves inverting the bits of b.

/// @notice versions >=0.8.0 && <0.8.16 with compiler optimizations
function checkedAddUint2(uint256 a, uint256 b) public pure returns (uint256 c) {
    unchecked {
        c = a + b;

        if (a > ~b) arithmeticError();
    }
}

This is process is equivalent, because type(uint256).max is a 256-bit integer with all its bits set to 1. Subtracting b from type(uint256).max can be viewed as inverting each bit in b. This transformation is demonstrated by ~b = ~(0 ^ b) = ~0 ^ b = MAX ^ b = MAX - b.

Note that a - b = a ^ b is NOT a general rule, except in special cases, such as when one of the values equals type(uint256).max. The relation ~b + 1 = 0 - b = -b is also obtained if we add 1 mod 2**256 to both sides of the previous equation.

By first calculating the result of the addition and then performing a check on the sum, the need performing extra arithmetic operations are removed. This is how the compiler implements arithmetic checks for unsigned integer addition in versions 0.8.16 and later.

/// @notice versions >=0.8.16
function checkedAddUint(uint256 a, uint256 b) public pure returns (uint256 c) {
    unchecked {
        c = a + b;

        if (a > c) arithmeticError();
    }
}

Overflow is detected when the sum is smaller than one of its addends. In other words, if a > a + b, then overflow has occurred. To fully prove this, it is necessary to verify that overflow occurs if and only if a > a + b. An important observation is that a > a + b (mod 2**256) for b > 0 is only possible when b >= 2**256, which exceeds the maximum possible value.

Arithmetic checks for int256 addition

The Solidity compiler generates the following (equivalent) code for detecting overflow in signed integer addition for versions below 0.8.16.

/// @notice versions >=0.8.0 && <0.8.16
function checkedAddInt(int256 a, int256 b) public pure returns (int256 c) {
    unchecked {
        c = a + b;

        // If `a > 0`, then `b` can't exceed `type(int256).max - a`.
        if (a > 0 && b > type(int256).max - a) arithmeticError();
        // If `a < 0`, then `b` can't be less than `type(int256).min - a`.
        if (a < 0 && b < type(int256).min - a) arithmeticError();
    }
}

Similar to the previous example, we can compute the maximum and minimum value of one addend, given that the other is either positive or negative.

For reference, this is the Yul code that is produced when compiling via IR.

function checked_add_t_int256(x, y) -> sum {
    x := cleanup_t_int256(x)
    y := cleanup_t_int256(y)

    // overflow, if x >= 0 and y > (maxValue - x)
    if and(iszero(slt(x, 0)), sgt(y, sub(0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff, x))) { panic_error_0x11() }
    // underflow, if x < 0 and y < (minValue - x)
    if and(slt(x, 0), slt(y, sub(0x8000000000000000000000000000000000000000000000000000000000000000, x))) { panic_error_0x11() }

    sum := add(x, y)
}

It's important to note that when comparing signed values, the opcodes slt (signed less than) and sgt (signed greater than) must be used to avoid interpreting signed integers as unsigned integers. Solidity will automatically insert the correct opcode based on the value's type. This applies to other signed operations as well.

Quick primer on a two's complement system

In a two's complement system, the range of possible integers is divided into two halves: the positive and negative domains. The first bit of an integer represents the sign, with 0 indicating a positive number and 1 indicating a negative number. For positive integers (those with a sign bit of 0), their binary representation is the same as their unsigned bit representation. However, the negative domain is shifted to lie "above" the positive domain.

$$\text{uint256 domain}$$

$$ ├\underset{\hskip -0.5em 0}{─}────────────────────────────\underset{\hskip -3em 2^{256} - 1}{─}┤ $$

0x0000000000000000000000000000000000000000000000000000000000000000 // 0
0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff // uint256_max

$$\text{int256 domain}$$

$$ \overset{positive}{ ├\underset{\hskip -0.5em 0}{─}────────────\underset{\hskip -3em 2^{255} - 1}{─}┤ } \overset{negative}{ ├──\underset{\hskip -2.1em - 2^{255}}{─}──────────\underset{\hskip -1 em -1}{─}┤ } $$

0x0000000000000000000000000000000000000000000000000000000000000000 // 0
0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff // int256_max
0x8000000000000000000000000000000000000000000000000000000000000000 // int256_min
0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff // -1

The maximum positive integer that can be represented in a two's complement system using 256 bits is 0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff which is roughly equal to half of the maximum value that can be represented using uint256. The most significant bit of this number is 0, while all other bits are 1.

On the other hand, all negative numbers start with a 1 as their first bit. If we look at the underlying hex representation of these numbers, they are all greater than or equal to the smallest integer that can be represented using int256, which is 0x8000000000000000000000000000000000000000000000000000000000000000. The integer's binary representation is a 1 followed by 255 0's.

To obtain the negative value of an integer in a two's complement system, we flip the underlying bits and add 1: -a = ~a + 1. An example illustrates this.

0x0000000000000000000000000000000000000000000000000000000000000003 // 3
0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc // ~3
0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd // -3 = ~3 + 1

To verify that -a + a = 0 holds for all integers, we can use the property of two's complement arithmetic that -a = ~a + 1. By substituting this into the equation, we get -a + a = (~a + 1) + a = MAX + 1 = 0, where MAX is the maximum integer value.

In two's complement arithmetic, there is a unique case that warrants special attention. The smallest possible integer int256).min = 0x8000000000000000000000000000000000000000000000000000000000000000 = -57896044618658097711785492504343953926634992332820282019728792003956564819968 does not have a positive inverse, making it the only negative number with this property.

Interestingly, if we try to compute -type(int256).min, we obtain the same number, as -type(int256).min = ~type(int256).min + 1 = type(int256).min. This means there are two fixed points for additive inverses: -0 = 0 and -type(int256).min = type(int256).min. It's important to note that Solidity's arithmetic checks will throw an error when evaluating -type(int256).min (outside of unchecked blocks).

Examining the underlying bit (or hex) representation emphasizes the importance of using the correct operators for signed integers, such as slt instead of lt, to prevent misinterpreting negative values as large numbers.

  0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff // int256(-1) or type(uint256).max
< 0x0000000000000000000000000000000000000000000000000000000000000000 // 0
// When using `slt`, the comparison is interpreted as `-1 < 0 = true`.
= 0x0000000000000000000000000000000000000000000000000000000000000001
// When using `lt`, the comparison is interpreted as `type(uint256).max < 0 = false`.
= 0x0000000000000000000000000000000000000000000000000000000000000000

Starting with Solidity versions 0.8.16, integer overflow is prevented by using the computed result c = a + b to check for overflow/underflow. However, signed addition requires two separate checks instead of one, unlike unsigned addition.

/// @notice versions >=0.8.16
function checkedAddInt2(int256 a, int256 b) public pure returns (int256 c) {
    unchecked {
        c = a + b;

        // If `a` is positive, then the sum `c = a + b` can't be less than `b`.
        if (a > 0 && c < b) arithmeticError();
        // If `a` is negative, then the sum `c = a + b` can't be greater than `b`.
        if (a < 0 && c > b) arithmeticError();
    }
}

Nevertheless, by utilizing the boolean exclusive-or, we can combine these checks into a single step. Although Solidity does not allow the xor operation for boolean values, it can be used in inline-assembly. While doing so, it is important to validate our assumptions that both inputs are genuinely boolean (either 0 or 1), as the xor operation functions bitwise and is not limited to only boolean values.

function checkedAddInt3(int256 a, int256 b) public pure returns (int256 c) {
    unchecked {
        c = a + b;

        bool overflow;

        assembly {
            // If `a >= 0`, then the sum `c = a + b` can't be less than `b`.
            // If `a <  0`, then the sum `c = a + b` can't be greater than `b`.
            // We combine these two conditions into one using `xor`.
            overflow := xor(slt(a, 0), sgt(b, c))
        }

        if (overflow) arithmeticError();
    }
}

An alternative approach to detecting overflow in addition is based on the observation that adding two integers with different signs will never result in an overflow. This simplifies the check to the case when both operands have the same sign. If the sign of the sum differs from one of the operands, the result has overflowed.

function checkedAddInt4(int256 a, int256 b) public pure returns (int256 c) {
    unchecked {
        c = a + b;

        // Overflow, if the signs of `a` and `b` are the same,
        // but the sign of the result `c = a + b` differs from its summands.
        // When the signs of `a` and `b` differ overflow is not possible.
        if ((~a ^ b) & (a ^ c) < 0) arithmeticError();
    }
}

Instead of checking the sign bit explicitly, which can be done by shifting the value to the right by 255 bits and verifying that it is non-zero, we can use the slt operation to compare the value with 0.

Arithmetic checks for uint256 subtraction

The process of checking for underflow in subtraction is similar to that of addition. When subtracting a - b, and b is greater than a, an underflow occurs.

function checkedSubUint(uint256 a, uint256 b) public pure returns (uint256 c) {
    unchecked {
        c = a - b;

        if (b > a) arithmeticError();
    }
}

Alternatively, we could perform the check on the result itself using if (c > a) arithmeticError();, because subtracting a positive value from a should yield a value less than or equal to a. However, in this case, we don't save any operations.

Similar to addition, for signed integers, we can combine the checks for both scenarios into a single check using xor.

function checkedSubInt(int256 a, int256 b) public pure returns (int256 c) {
    unchecked {
        c = a - b;

        bool overflow;

        assembly {
            // If `b >= 0`, then the result `c = a - b` can't be greater than `a`.
            // If `b <  0`, then the result `c = a - b` can't be less than `a`.
            overflow := xor(sgt(b, 0), sgt(a, c))
        }

        if (overflow) arithmeticError();
    }
}

Arithmetic checks for uint256 multiplication

To detect overflow when multiplying two unsigned integers, we can use the approach of computing the maximum possible value of a multiplicand and check that it isn't exceeded.

/// @notice versions >=0.8.0 && <0.8.17
function checkedMulUint1(uint256 a, uint256 b) public pure returns (uint256 c) {
    unchecked {
        c = a * b;

        if (a != 0 && b > type(uint256).max / a) arithmeticError();
    }
}

The Solidity compiler always includes a zero check for all division and modulo operations, irrespective of whether an unchecked block is present. The EVM itself, however, returns 0 when dividing by 0, which applies to inline-assembly as well. Evaluating the boolean expression a != 0 && b > type(uint256).max / a in reverse order would cause an incorrect reversion when a = 0.

We can compute the maximum value for b as long as a is non-zero. However, if a is zero, we know that the result will be zero as well, and there is no need to check for overflow. Like before, we can also make use of the result and try to reconstruct one multiplicand from it. This is possible if the product didn't overflow and the first multiplicand is non-zero.

/// @notice versions >=0.8.17
function checkedMulUint2(uint256 a, uint256 b) public pure returns (uint256 c) {
    unchecked {
        c = a * b;

        if (a != 0 && b != c / a) arithmeticError();
    }
}

For reference, we can further remove the additional division by zero check by writing the code in assembly.

function checkedMulUint3(uint256 a, uint256 b) public pure returns (uint256 c) {
    unchecked {
        c = a * b;

        bool overflow;

        assembly {
            // This version does not include a redundant division-by-0 check
            // which the Solidity compiler includes when performing `c / a`.
            overflow := iszero(or(iszero(a), eq(div(c, a), b)))
        }

        if (overflow) arithmeticError();
    }
}

Arithmetic checks for int256 multiplication

In versions before 0.8.17, the Solidity compiler uses four separate checks to detect integer multiplication overflow. The produced Yul code is equivalent to the following high-level Solidity code.

/// @notice versions >=0.8.0 && <0.8.17
function checkedMulInt(int256 a, int256 b) public pure returns (int256 c) {
    unchecked {
        c = a * b;

        if (a > 0 && b > 0 && a > type(int256).max / b) arithmeticError();
        if (a > 0 && b < 0 && a < type(int256).min / b) arithmeticError();
        if (a < 0 && b > 0 && a < type(int256).min / b) arithmeticError();
        if (a < 0 && b < 0 && a < type(int256).max / b) arithmeticError();
    }
}

Since Solidity version 0.8.17, the check is performed by utilizing the computed product in the check.

/// @notice versions >=0.8.17
function checkedMulInt2(int256 a, int256 b) public pure returns (int256 c) {
    unchecked {
        c = a * b;

        if (a < 0 && b == type(int256).min) arithmeticError();
        if (a != 0 && b != c / a) arithmeticError();
    }
}

When it comes to integer multiplication, it's important to handle the case when a < 0 and b == type(int256).min. The actual case, where the product c will overflow, is limited to a == -1 and b == type(int256).min. This is because -b cannot be represented as a positive signed integer, as previously mentioned.

Arithmetic checks for addition with sub-32-byte types

When performing arithmetic checks on data types that use less than 32 bytes, there are some additional steps to consider. First, let's take a look at the addition of signed 64-bit integers.

On a 64-bit system, integer addition works in the same way as before.

  0xfffffffffffffffe // int64(-2)
+ 0x0000000000000003 // int64(3)
= 0x0000000000000001 // int64(1)

However, when performing the same calculations on a 256-bit machine, we need to extend the sign of the int64 value over all unused bits, otherwise the value won't be interpreted correctly.

                                   extended sign ──┐┌── 64-bit information
  0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe // int64(-2)
+ 0x0000000000000000000000000000000000000000000000000000000000000003 // int64(3)
= 0x0000000000000000000000000000000000000000000000000000000000000001 // int64(1)

It's worth noting that not all operations require clean upper bits. In fact, even if the upper bits are dirty, we can still get correct results for addition. However, the sum will usually contain dirty upper bits that will need to be cleaned. For example, we can perform addition without knowledge of the upper bits.

  0x????????????????????????????????????????????????fffffffffffffffe // int64(-2)
+ 0x????????????????????????????????????????????????0000000000000003 // int64(3)
= 0x????????????????????????????????????????????????0000000000000001 // int64(1)

It is crucial to be mindful of when to clean the bits before and after operations. By default, Solidity takes care of cleaning the bits before operations on smaller types and lets the optimizer remove any redundant steps. However, values accessed after operations included by the compiler are not guaranteed to be clean. In particular, this is the case for addition with small data types. For example, the bit cleaning steps will be removed by the optimizer (even without optimizations enabled) if a variable is only accessed in a subsequent assembly block. Refer to the Solidity documentation for further information on this matter.

When performing arithmetic checks in the same way as before, it is necessary to include a step to clean the bits on the sum. One approach to achieve this is by performing signextend(7, value), which extends the sign of a 64-bit (7 + 1 = 8 bytes) integer over all upper bits.

function checkedAddInt64_1(int64 a, int64 b) public pure returns (int64 c) {
    unchecked {
        bool overflow;

        c = a + b;

        assembly {
            // Note that we must manually clean the upper bits in this case.
            // Solidity will optimize the cleaning away otherwise.
            // Extend the sign of the sum to 256 bits.
            c := signextend(7, c)

            // Perform the same arithmetic overflow check as before.
            overflow := xor(slt(a, 0), sgt(b, c))
        }

        if (overflow) arithmeticError();
    }
}

If we remove the line that includes c := signextend(7, c) the overflow check will not function correctly. This is because Solidity does not take into account the fact that the variable is used in an assembly block, and the optimizer removes the bit cleaning operation, even if the Yul code includes it after the addition.

One thing to keep in mind is that since we are performing a 64-bit addition in 256 bits, we practically have access to the carry/overflow bits. If our computed value does not overflow, then it will fall within the correct bounds type(int64).min <= c <= type(int64).max. The actual overflow check in Solidity involves verifying both the upper and lower bounds.

/// @notice version >= 0.8.16
function checkedAddInt64_2(int64 a, int64 b) public pure returns (int64 c) {
    unchecked {
        // Perform the addition in int256.
        int256 uc = int256(a) + b;

        // If the value can not be represented by a int64, there is overflow.
        if (uc > type(int64).max || uc < type(int64).min) arithmeticError();

        // We can safely cast the result.
        c = int64(uc);
    }
}

There are a few ways to verify that the result in its 256-bit representation will fit into the expected data type. This is only true when all upper bits are the same. The most direct method, as previously shown, involves verifying both the lower and upper bounds.

/// @notice Check used in int64 addition for version >= 0.8.16.
function overflowInt64(int256 value) public pure returns (bool overflow) {
    overflow = value > type(int64).max || value < type(int64).min;
}

We can simplify the expression to a single comparison if we can shift the disjointed number domain back so that it's connected. To accomplish this, we subtract the smallest negative int64 (type(int64).min) from a value (or add the underlying unsigned value). A better way to understand this is by visualizing the signed integer number domain in relation to the unsigned domain (which is demonstrated here using int128).

$$\text{uint256 domain}$$

$$ ├\underset{\hskip -0.5em 0}{─}────────────────────────────\underset{\hskip -3em 2^{256} - 1}{─}┤ $$

$$\text{int256 domain}$$

$$ \overset{positive}{ ├\underset{\hskip -0.5em 0}{─}────────────\underset{\hskip -3em 2^{255} - 1}{─}┤ } \overset{negative}{ ├──\underset{\hskip -2.1em - 2^{255}}{─}──────────\underset{\hskip -1 em -1}{─}┤ } $$

The domain for uint128/int128 can be visualized as follows.

$$\text{uint128 domain}$$

$$ ├\underset{\hskip -0.5em 0}─────────────\underset{\hskip -3em 2^{128}-1}─┤ \hskip 7em┆ $$

$$\text{int128 domain}$$

$$ ├\underset{\hskip -0.5em 0}{─}────\underset{\hskip -3em 2^{127} - 1}─\overset{\hskip -3em positive}{┤} \hskip 7em ├──\underset{\hskip -2.1em - 2^{127}}───\underset{\hskip -1 em -1}{─}\overset{\hskip -3em negative}{┤} $$

Note that the scales of the number ranges in the previous section do not accurately depict the magnitude of numbers that are representable with the different types and only serve as a visualization. We can represent twice as many numbers with only one additional bit, yet the uint256 domain has twice the number of bits compared to uint128.

After subtracting type(int128).min (or adding 2**127) and essentially shifting the domains to the right, we get the following, connected set of values.

$$ ├\underset{\hskip -0.5em 0}{─}────────────\underset{\hskip -3em 2^{128}-1}─┤ \hskip 7em┆ $$

$$ ├──────\overset{\hskip -3em negative}{┤} ├──────\overset{\hskip -3em positive}{┤} \hskip 7em┆ $$

If we interpret the shifted value as an unsigned integer, we only need to check whether it exceeds the maximum unsigned integer type(uint128).max. The corresponding check in Solidity is shown below.

function overflowInt64_2(int256 value) public pure returns (bool overflow) {
    unchecked {
        overflow = uint256(value) - uint256(int256(type(int64).min)) > type(uint64).max;
    }
}

In this case the verbose assembly code might actually be easier to follow than the Solidity code which sometimes contains implicit operations.

int64 constant INT64_MIN = -0x8000000000000000;
uint64 constant UINT64_MAX = 0xffffffffffffffff;

function overflowInt64_2_yul(int256 value) public pure returns (bool overflow) {
    assembly {
        overflow := gt(sub(value, INT64_MIN), UINT64_MAX)
    }
}

As mentioned earlier, this approach is only effective for negative numbers when all of their upper bits are set to 1, allowing us to overflow back into the positive domain. An alternative and more straightforward method would be to simply verify that all of the upper bits are equivalent to the sign bit for all integers.

function overflowInt64_3(int256 value) public pure returns (bool overflow) {
    overflow = value != int64(value);
}

In Yul, the equivalent resembles the following.

function overflowInt64_3_yul(int256 value) public pure returns (bool overflow) {
    assembly {
        overflow := iszero(eq(value, signextend(7, value)))
    }
}

Another way of extending the sign is to make use of sar (signed arithmetic right shift).

function overflowInt64_4(int256 value) public pure returns (bool overflow) {
    overflow = value != (value << 192) >> 192;
}

function overflowInt64_4_yul(int256 value) public pure returns (bool overflow) {
    assembly {
        overflow := iszero(eq(value, sar(192, shl(192, value))))
    }
}

Finally, a full example for detecting signed 64-bit integer overflow, implemented in Solidity can be seen below:

function checkedAddInt64_2(int64 a, int64 b) public pure returns (int64 c) {
    unchecked {
        // Cast the first summand.
        // The second summand is implicitly casted.
        int256 uc = int256(a) + b;

        // Check whether the result `uc` can be represented by 64 bits
        // by shifting the values to the uint64 domain.
        // This is done by subtracting the smallest value in int64.
        if (uint256(uc) - uint256(int256(type(int64).min)) > type(uint64).max) arithmeticError();

        // We can safely cast the result.
        c = int64(uc);
    }
}

One further optimization that we could perform is to add -type(int64).min instead of subtracting type(int64).min. This would not reduce computation costs, however it could end up reducing bytecode size. This is because when we subtract -type(int64).min, we need to push 32 bytes (0xffffffffffffffffffffffffffffffffffffffffffffffff8000000000000000), whereas when we add -type(int64).min, we only end up pushing 8 bytes (0x8000000000000000). However, as soon as we turn on compiler optimizations, the produced bytecode ends up being the same.

Arithmetic checks for multiplication with sub-32-byte types

When the product c = a * b can be calculated in 256 bits without the possibility of overflowing, we can verify whether the result can fit into the anticipated data type. This is also the way Solidity handles the check in versions 0.8.17 and later.

/// @notice version >= 0.8.17
function checkedMulInt64(int64 a, int64 b) public pure returns (int64 c) {
    unchecked {
        int256 uc = int256(a) * int256(b);

        // If the product can not be represented with 64 bits,
        // there is overflow.
        if (overflowInt64(uc)) arithmeticError();

        c = int64(uc);
    }
}

However, if the maximum value of a product exceeds 256 bits, then this method won't be effective. This happens, for instance, when working with int192. The product type(int192).min * type(int192).min requires 192 + 192 = 384 bits to be stored, which exceeds the maximum of 256 bits. Overflow occurs in 256 bits, causing a loss of information, and it won't be logical to check if the result fits into 192 bits. In this scenario, we can rely on the previous checks and, for example, attempt to reconstruct one of the multiplicands.

function checkedMulInt192_1(int192 a, int192 b) public pure returns (int192 c) {
    unchecked {
        c = a * b;

        if (a != 0 && b != c / a) arithmeticError();
        if (a = -1 && b == type(int192).min) arithmeticError();
    }
}

We must consider the two special circumstances:

  1. When one of the multiplicands is zero (a == 0), the other multiplicand cannot be retrieved. However, this case never results in overflow.
  2. Even if the multiplication is correct in 256 bits, the calculation overflows when only examining the least-significant 192 bits if the first multiplicand is negative one (a = -1) and the other multiplicand is the minimum value.

An example might help explain the second case.

  0xffffffffffffffff800000000000000000000000000000000000000000000000 // type(int192).min
* 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff // -1
= 0x0000000000000000800000000000000000000000000000000000000000000000 // type(int192).min (when looking at the first 192 bits)

A method to address this issue is to always start by sign-extending or cleaning the result before attempting to reconstruct the other multiplicand. By doing so, it eliminates the need to check for the special condition.

/// @notice version >= 0.8.17
function checkedMulInt192_2(int192 a, int192 b) public pure returns (int192 c) {
    unchecked {
        bool overflow;

        assembly {
            // Extend the sign for int192 (24 = 23 + 1 bytes).
            c := signextend(23, mul(a, b))

            // Overflow, if `a != 0 && b != c / a`.
            overflow := iszero(or(iszero(a), eq(b, sdiv(c, a))))
        }

        if (overflow) arithmeticError();
    }
}

Conclusion

In conclusion, we hope this article has served as an informative guide on signed integer arithmetic within the EVM and the two's complement system. We have explored:

  • How the EVM makes use of the two's complement representation
  • How integer values are interpreted as signed or unsigned depending on the opcodes used
  • The added complexity from handling arithmetic for signed vs. unsigned integers
  • The intricacies involved in managing sub 32-byte types
  • The importance of bit-cleaning and the significance of signextend

While low-level optimizations are attractive, they are also heavily error-prone. This article aims to deepen one's understanding of low-level arithmetic, to reduce these risks. Nevertheless, it is crucial to integrate custom low-level optimizations only after thorough manual analysis, automated testing, and to document any non-obvious assumptions.

Ethereum Yellow Paper

So, you want to read the Yellow Paper? Before we dive in, keep in mind that the Yellow Paper is outdated, and some in the community might refer to it as being deprecated. Check out the yellowpaper repository on GitHub and its BRANCHES.md file to stay up-to-date on how closely this document tracks the latest version of the Ethereum protocol. At the time of writing, the Yellow Paper is up to date with the Berlin hardfork, which occurred in April 2021. For an overview of all Ethereum forks and which EIPs are included in each of them, see the EIPs Forks page.

For a more up-to-date reference, check out the Ethereum Specification, which features a detailed description of each opcode for each hardfork in addition to reference implementations written in Python.

That said, the Yellow Paper is still a rich resource for ramping up on the fundamentals of the Ethereum protocol. This document aims to provide some guidance and assistance in deciphering Ethereum's flagship specification.

Mathematical Symbols

One challenging part of the Yellow Paper, for those of us who are not well-trained in formal mathematics, is comprehending the mathematical symbols. A cheat-sheet of some of these symbols is provided below:

  • : there exists
  • : for all
  • : and
  • : or

And some more Ethereum-specific symbols:

  • N_{H}: 1,150,000, aka block number at which the protocol was upgraded from Homestead to Frontier.
  • T: a transaction, e.g., T = {n: nonce, p: gasPrice, g: gasLimit, t: to, v: value, i: initBytecode, d: data}
  • S(): returns the sender of a transaction, e.g., S(T) = T.from
  • Λ: (lambda) account creation function
  • KEC: Keccak SHA-3 hash function
  • RLP: Recursive Length Prefix encoding

High-level Glossary

The following are symbols and function representations that provide a high-level description of Ethereum. Many of these symbols represent a data structure, the details of which are described in subsequent sections.

  • σ: Ethereum world state
  • B: block
  • μ: EVM state
  • A: accumulated transaction sub-state
  • I: execution environment
  • o: output of H(μ,I); i.e., null if we're good to go or a set of data if execution should halt
  • Υ(σ,T) => σ': the transaction-level state transition function
  • Π(σ,B) => σ': the block-level state transition function; processes all transactions then finalizes with Ω
  • Ω(B,σ) => σ: block-finalization state transition function
  • O(σ,μ,A,I): one iteration of the execution cycle
  • H(μ,I) => o: outputs null while execution should continue or a series if execution should halt.

Ethereum World-State: σ

A mapping between addresses (external or contract) and account states. Saved as a Merkle-Patricia tree whose root is recorded on the blockchain backbone.

σ = [account1={...}, account2={...},
  account3={
    n: nonce, aka number of transactions sent by account3
    b: balance, i.e., the number of wei account3 controls
    s: storage root, hash of the Merkle-Patricia tree that contains this account's long-term data store
    c: code, hash of the EVM bytecode that controls this account; if this equals the hash of an empty string, this is a non-contract account.
  }, ...
]

The Block: B

B = Block = {
  H: Header = {
    p: parentHash,
    o: ommersHash,
    c: beneficiary,
    r: stateRoot,
    t: transactionsRoot,
    e: receiptsRoot,
    b: logsBloomFilter,
    d: difficulty,
    i: number,
    l: gasLimit,
    g: gasUsed,
    s: timestamp,
    x: extraData,
    m: mixHash,
    n: nonce,
  },
  T: Transactions = [
    tx1, tx2...
  ],
  U: Uncle block headers = [
    header1, header2...
  ],
  R: Transaction Receipts = [
    receipt_1 = {
      σ: root hash of the ETH state after transaction 1 finishes executing,
      u: cumulative gas used immediately after this tx completes,
      b: bloom filter,
      l: set of logs created while executing this tx
    }
  ]
}

Execution Environment: I

I = Execution Environment = {
  a: address(this), address of the account which owns the executing code
  o: tx.origin, original sender of the tx that initialized this execution
  p: tx.gasPrice, price of gas
  d: data, aka byte array of method id & args
  s: sender of this tx or initiator of this execution
  v: value sent along with this execution or transaction
  b: byte array of machine code to be executed
  H: header of the current block
  e: current stack depth
}

EVM State: μ

The state of the EVM during execution. This is the data structure provided by the debug_traceTransaction JSON RPC method. See this page for more details about using this method to investigate transaction execution.

μ = {
  g: gas left
  pc: program counter, i.e., index into which instruction of I.b to execute next
  m: memory contents, lazily initialized to 2^256 zeros
  i: number of words in memory
  s: stack contents
}

Accrued Sub-state: A

The data accumulated during tx execution that needs to be available at the end to finalize the transaction's state changes.

A = {
  s: suicide set, i.e., the accounts to delete at the end of this tx
  l: logs
  t: touched accounts
  r: refunds, e.g., gas received when storage is freed
}

Contract Creation

If we send a transaction tx to create a contract, tx.to is set to null, and we include a tx.init field that contains bytecode. This is NOT the bytecode run by the contract. Rather, it RETURNS the bytecode run by the contract, i.e., the tx.init code is run ONCE at contract creation and never again.

If T.to == 0, then this is a contract creation transaction, and T.init != null, T.data == null.

The table below lists all EIPs associated with Ethereum forks:

ForkEIPWhat it doesOpcodeGasNotes
Homestead (606)2Homestead Hard-fork ChangesX
Homestead (606)7DelegatecallX
Homestead (606)8Networking layer: devp2p Forward Compatibility Requirements for Homestead
DAO Fork (779)779DAO Fork
Tangerine Whistle (608)150Gas cost changes for IO-heavy operationsXIntroduces the all but one 64th rule
Spurious Dragon (607)155Simple replay attack protection
Spurious Dragon (607)160EXP cost increaseX
Spurious Dragon (607)161State trie clearing (invariant-preserving alternative)X
Spurious Dragon (607)170Contract code size limitAlters the semantics of CREATE
Byzantium (609)100Change difficulty adjustment to target mean block time including uncles
Byzantium (609)140REVERT instructionX
Byzantium (609)196Precompiled contracts for addition and scalar multiplication on the elliptic curve alt_bn128
Byzantium (609)197Precompiled contracts for optimal ate pairing check on the elliptic curve alt_bn128
Byzantium (609)198Precompiled contract for bigint modular exponentiation
Byzantium (609)211RETURNDATASIZE and RETURNDATACOPYX
Byzantium (609)214STATICCALLX
Byzantium (609)649Metropolis Difficulty Bomb Delay and Block Reward Reduction
Byzantium (609)658Embedding transaction status code in receipts
Constantinople (1013)145Bitwise shifting instructions in EVMX
Constantinople (1013)1014Skinny CREATE2X
Constantinople (1013)1234Constantinople Difficulty Bomb Delay and Block Reward Adjustment
Constantinople (1013)1283Net gas metering for SSTORE without dirty mapsXThis EIP leads to reentrancies risks (see EIP-1283 incident report) and was directly removed with EIP-1716
Petersburg (1716)1716Remove EIP-1283XSee EIP-1283 incident report
Istanbul (1679)152Precompiled contract for the BLAKE2 F compression function
Istanbul (1679)1108Reduce alt_bn128 precompile gas costsX
Istanbul (1679)1344ChainID opcodeX
Istanbul (1679)1884Repricing for trie-size-dependent opcodesXXThe EIP changes the gas cost of multiple opcodes, and add SELFBALANCE
Istanbul (1679)2028Transaction data gas cost reductionX
Istanbul (1679)2200Structured Definitions for Net Gas MeteringX
Muir Glacier (2387)2384Istanbul/Berlin Difficulty Bomb Delay
Berlin (2070)2565ModExp Gas CostX
Berlin (2070)2929Gas cost increases for state access opcodesX
Berlin (2718)2718Typed Transaction Envelope
Berlin (2718)2930Typed Transaction Envelope
London1559Fee market change for ETH 1.0 chainXSignificant modifications of Ethereum gas pricing
London3198BASEFEEX
London3529Reduction in refundsXRemove gas tokens benefits
London3554Difficulty Bomb Delay to December 1st 2021
Arrow Glacier4345Difficulty Bomb Delay to June 2022
Gray Glacier5133Difficulty Bomb Delay to mid-September 2022
Paris3675Upgrade consensus to Proof-of-StakeChanges to DIFFICULTY and BLOCKHASH
Paris4399Supplant DIFFICULTY opcode with PREVRANDAOXDIFFICULTY becomes PREVRANDAO

In this table:

  • Opcode: The EIP adds or removes an opcode
  • Gas: The EIP changes the gas rules

The following list presents every CIP associated with a Celo fork. Celo is an EVM-compatible chain.

ForkCIP/EIPWhat it does
ChurritoEIP 211Creates RETURNDATASIZE and RETURNDATACOPY opcodes
DonutCIP 25Adds Ed25519 precompile
DonutCIP 31 - copied from EIP-2539Adds precompile for BLS12-381 curve operations
DonutCIP 30 - copied from EIP-2539Adds precompile for BLS12-377 curve operations
DonutCIP 20Adds extensible hash function precompile
DonutCIP 21Adds governable lookback window
DonutCIP 22Upgrades epoch SNARK data
DonutCIP 26Adds precompile to return BLS pubkey of given validator
DonutCIP 28Splits etherbase into separate addresses
DonutCIP 35Adds support for Ethereum-compatible transactions
EspressoEIP 2565Defines gas cost of ModExp precompile
EspressoCIP 48 - modified from EIP 2929Gas repricing
EspressoEIP 2718Introduces typed transaction envelope
EspressoEIP 2930Introduces optional access lists
EspressoCIP 42 - modified from EIP 1559Fee market changes
EspressoEIP 3529Reduction in gas refunds
EspressoEIP 3541Rejects deployment of contract code starting with the 0xEF byte
EspressoCIP 43Incorporates Block Context
EspressoCIP 47Modifies round change timeout formula
EspressoCIP 45Modifies transaction fee check
EspressoCIP 50Makes replay protection optional

The following list comprises every TIP associated with a TRON upgrade. TRON is an EVM-compatible chain.

UpgradeTIPWhat it does
Odyssey-v3.512Introduces event subscription model
Odyssey-v3.516Supports account multi-signature and different permissions
Odyssey-v3.517Implements adaptive energy upper limit
Odyssey-v3.5.124Offers RocksDB as a storage engine
Odyssey-v3.6.026Adds create2 instruction to TVM
Odyssey-v3.6.028Integrates built-in message queue in event subscription model
Odyssey-v3.6.029Adds bitwise shifting instructions to TVM
Odyssey-v3.6.030Adds extcodehash instruction to TVM to return keccak256 hash of a contract's code
Odyssey-v3.6.031Adds triggerConstantContract API to support contracts without ABI
Odyssey-v3.6.032Adds clearContractABI API to clear existing ABI of contract
Odyssey-v3.6.141Optimizes transaction history store occupancy space
Odyssey-v3.6.537Prohibits use of TransferContract & TransferAssetContract for contract account
Odyssey-v3.6.543Adds precompiled contract function batchvalidatesign to TVM that supports parallel signature verification
Odyssey-v3.6.544Adds ISCONTRACT opcode
Odyssey-v3.6.553Optimizes current TRON delegation mechanism
Odyssey-v3.6.554Supports automatic account activation when transferring TRX/TRC10 tokens in contracts
Odyssey-v3.6.560Adds validatemultisign instruction to TVM to support multi-signature verification
GreatVoyage-v4.0.0135Introduces shielded TRC-20 contract standards
GreatVoyage-v4.0.0137Add ZKP verification functions to shielded TRC-20 contract - verifyMintProof, verifyTransferProof, and verifyBurnProof
GreatVoyage-v4.0.0138Add Pedersen hash computation pedersenHash function to shielded TRC-20 contract
GreatVoyage-v4.1.0127Add new system contracts to support token exchange (including TRX and TRC-10)
GreatVoyage-v4.1.0128Add new node type: Lite Fullnode
GreatVoyage-v4.1.0174Add CHAINID instruction to TVM
GreatVoyage-v4.1.0175Add SELFBALANCE instruction to TVM
GreatVoyage-v4.1.0176altbn128-related operation energy reduction in TVM
GreatVoyage-v4.1.2196Reward SRs with tx fees
GreatVoyage-v4.1.2204MAX_FEE_LIMIT is configurable
GreatVoyage-v4.1.2209Adapt Solidity compilers to Solidity 0.6.0
GreatVoyage-v4.2.0(Plato)157Add freeze instructions to TVM - FREEZE, UNFREEZE, and FREEZEEXPIRETIME
GreatVoyage-v4.2.0(Plato)207Optimize TRX freezing resource utilization
GreatVoyage-v4.2.2(Lucretius)268ABI optimization - Move ABI out of SmartContract and store it in a new ABI store to reduce execution speeds of certain opcodes
GreatVoyage-v4.2.2(Lucretius)269Optimize block processing speed
GreatVoyage-v4.2.2(Lucretius)281Optimize database query performance
GreatVoyage-v4.3.0(Bacon)271Add vote instructions and precompile contracts to TVM
GreatVoyage-v4.3.0(Bacon)276Optimize block verification logic
GreatVoyage-v4.3.0(Bacon)285Optimize node startup
GreatVoyage-v4.3.0(Bacon)292Adjust account free net limit
GreatVoyage-v4.3.0(Bacon)293Adjust total net limit
GreatVoyage-v4.3.0(Bacon)295Optimize account data structure
GreatVoyage-v4.3.0(Bacon)298Add new plugin to optimize levelDB performance startup
GreatVoyage-v4.3.0(Bacon)306Add Error type in smart contract ABI
GreatVoyage-v4.4.0(Rousseau)289Block broadcasting optimization
GreatVoyage-v4.4.0(Rousseau)290Optimize dynamic database query performance
GreatVoyage-v4.4.0(Rousseau)272TVM compatibility with EVM
GreatVoyage-v4.4.0(Rousseau)318Adapt to Ethereum London Upgrade
GreatVoyage-v4.4.2(Augustinus)343Optimize levelDB read performance
GreatVoyage-v4.4.2(Augustinus)343Optimize TVM instruction execution
GreatVoyage-v4.4.4(Plotinus)362Optimize node broadcast data caching
GreatVoyage-v4.4.4(Plotinus)366Optimize node startup process
GreatVoyage-v4.5.1(Tertullian)369Support prometheus (metrics interface)
GreatVoyage-v4.5.1(Tertullian)370Support node conditionalized stop
GreatVoyage-v4.5.1(Tertullian)382Optimize account assets data structure
GreatVoyage-v4.5.1(Tertullian)383Optimize transaction cache loading
GreatVoyage-v4.5.1(Tertullian)388Optimize light node synchronization logic
GreatVoyage-v4.5.1(Tertullian)391Optimize block process and broadcasting logic
GreatVoyage-v4.5.1(Tertullian)397Raise limit of the 13th network parameter
GreatVoyage-v4.5.2(Aurelius)425Speed up TCP connection establishment.
GreatVoyage-v4.5.2(Aurelius)440Optimize transaction cache
GreatVoyage-v4.5.2(Aurelius)428Optimize lock competition in block processing
GreatVoyage-v4.6.0(Socrates)461Upgrade checkpoint mechanism to V2 in database module
GreatVoyage-v4.6.0(Socrates)476Optimize delegate data structure
GreatVoyage-v4.6.0(Socrates)387Add transaction memo fee
GreatVoyage-v4.6.0(Socrates)465Optimize reward calculation algorithm

The following list includes each BEP associated with a Binance Smart Chain fork.

ReleaseBEPFunctionality
v1.0.684Issue or bind BEP2 with existing BEP20 tokens
v1.1.593Introduce new block synchronization protocol
v1.1.595Establish real-time burning mechanism
v1.1.11127Implement "Temporary Maintenance" mode for validators
v1.1.11131Expand validator set with "Candidate" validators
v1.1.18153Develop native staking protocol

(Not So) Smart Contracts

This repository contains examples of common smart contract vulnerabilities, including code from real smart contracts. Use Not So Smart Contracts to learn about vulnerabilities, as a reference when performing security reviews, and as a benchmark for security and analysis tools:

(Not So) Smart Contracts

This repository contains examples of common Algorand smart contract vulnerabilities, including code from real smart contracts. Use Not So Smart Contracts to learn about Algorand vulnerabilities, as a reference when performing security reviews, and as a benchmark for security and analysis tools.

Features

Each Not So Smart Contract includes a standard set of information:

  • Description of the vulnerability type
  • Attack scenarios to exploit the vulnerability
  • Recommendations to eliminate or mitigate the vulnerability
  • Real-world contracts that exhibit the flaw
  • References to third-party resources with more information

Vulnerabilities

Not So Smart ContractDescriptionApplicable to smart signaturesApplicable to smart contracts
RekeyingAttacker rekeys an accountyesyes
Unchecked Transaction FeesAttacker sets excessive fees for smart signature transactionsyesno
Closing AccountAttacker closes smart signature accountsyesno
Closing AssetAttacker transfers entire asset balance of a smart signatureyesno
Group Size CheckContract does not check transaction group sizeyesyes
Time-based Replay AttackContract does not use lease for periodic paymentsyesno
Access ControlsContract does not enfore access controls for updating and deleting applicationnoyes
Asset Id CheckContract does not check asset id for asset transfer operationsyesyes
Denial of ServiceAttacker stalls contract execution by opting out of a assetyesyes
Inner Transaction FeeInner transaction fee should be set to zeronoyes
Clear State Transaction CheckContract does not check OnComplete field of an Application Callyesyes

Credits

These examples are developed and maintained by Trail of Bits.

If you have questions, problems, or just want to learn more, then join the #ethereum channel on the Empire Hacking Slack or contact us directly.

Rekeying

The lack of check for RekeyTo field in the Teal program allows malicious actors to rekey the associated account and control the account assets directly, bypassing the restrictions imposed by the Teal contract.

Description

Rekeying is an Algorand feature which allows a user to transfer the authorization power of their account to a different account. When an account has been rekeyed, all the future transactions from that account are accepted by the blockchain, if and only if the transaction has been authorized by the rekeyed account.

A user can rekey their account to the selected account by sending a rekey-to transaction with rekey-to field set to the target account address. A rekey-to transaction is atransaction which has the rekey-to field set to a well formed Algorand address. Any algorand account can be rekeyed by sending a rekey-to transaction from that account, this includes the contract accounts.

Contract accounts are accounts which are derived from the Teal code that is in control of that account. Anyone can set the fields and submit a transaction from the contract account as long as it passes the checks enforced in the Teal code. This results in an issue if the Teal code is supposed to approve a transaction that passes specific checks and does not check the rekey-to field. A malicious user can first send a transaction approved by the Teal code with rekey-to set to their account. After rekeying, the attacker can transfer the assets, algos directly by authorizing the transactions with their private key.

Similar issue affects the accounts that created a delegate signature by signing a Teal program. Delegator is only needed to sign the contract and any user with access to delegate signature can construct and submit transactions. Because of this, a malicious user can rekey the sender’s account if the Teal logic accepts a transaction with the rekey-to field set to the user controlled address.

Note: From Teal v6, Applications can also be rekeyed by executing an inner transaction with "RekeyTo" field set to a non-zero address. Rekeying an application allows to bypass the application logic and directly transfer Algos and assets of the application account.

Exploit Scenarios

A user creates a delegate signature for recurring payments. Attacker rekeys the sender’s account by specifying the rekey-to field in a valid payment transaction.

Example

Note: This code contains several other vulnerabilities, Unchecked Transaction Fees, Closing Account, Time-based Replay Attack.

def withdraw(
    duration,
    period,
    amount,
    receiver,
    timeout,
):
    return And(
        Txn.type_enum() == TxnType.Payment,
        Txn.first_valid() % period == Int(0),
        Txn.last_valid() == Txn.first_valid() + duration,
        Txn.receiver() == receiver,
        Txn.amount() == amount,
        Txn.first_valid() < timeout,
    )

Recommendations

  • For the Teal programs written in Teal version 2 or greater, either used as delegate signature or contract account, include a check in the program that verifies rekey-to field to be equal to ZeroAddress or any intended address. Teal contracts written in Teal version 1 are not affected by this issue. Rekeying feature is introduced in version 2 and Algorand rejects transactions that use features introduced in the versions later than the executed Teal program version.

  • Use Tealer to detect this issue.

  • For Applications, verify that user provided value is not used for RekeyTo field of a inner transaction. Additionally, avoid rekeying an application to admin controlled address. This allows for the possibility of "rug pull" by a malicious admin.

Unchecked Transaction Fee

Lack of transaction fee check in smart signatures allows malicious users to drain the contract account or the delegator’s account by specifying excessive fees.

Description

Any user can submit transactions using the smart signatures and decide on the transaction fields. It is the responsibility of the creator to enforce restrictions on all the transaction fields to prevent malicious users from misusing the smart signature.

One of these transaction fields is Fee. Fee field specifies the number of micro-algos paid for processing the transaction. Protocol only verifies that the transaction pays a fee greater than protocol decided minimum fee. If a smart signature doesn’t bound the transaction fee, a user could set an excessive fee and drain the sender funds. Sender will be the signer of the Teal program in case of delegate signature and the contract account otherwise.

Exploit Scenarios

A user creates a delegate signature for recurring payments. Attacker creates a valid transaction and drains the user funds by specifying excessive fee.

Examples

Note: This code contains several other vulnerabilities, see Rekeying, Closing Account, Time-based Replay Attack.

def withdraw(
    duration,
    period,
    amount,
    receiver,
    timeout,
):
    return And(
        Txn.type_enum() == TxnType.Payment,
        Txn.first_valid() % period == Int(0),
        Txn.last_valid() == Txn.first_valid() + duration,
        Txn.receiver() == receiver,
        Txn.amount() == amount,
        Txn.first_valid() < timeout,
    )

Recommendations

  • Force the transaction fee to be 0 and use fee pooling. If the users should be able to call the smart signature outside of a group, force the transaction fee to be minimum transaction fee: global MinTxnFee.

Closing Account

Lack of check for CloseRemainderTo transaction field in smart signatures allows attackers to transfer entire funds of the contract account or the delegator’s account to their account.

Description

Algorand accounts must satisfy minimum balance requirement and protocol rejects transactions whose execution results in account balance lower than the required minimum. In order to transfer the entire balance and close the account, users should use the CloseRemainderTo field of a payment transaction. Setting the CloseRemainderTo field transfers the entire account balance remaining after transaction execution to the specified address.

Any user with access to the smart signature may construct and submit the transactions using the smart signature. The smart signatures approving payment transactions have to ensure that the CloseRemainderTo field is set to the ZeroAddress or any other specific address to avoid unintended transfer of funds.

Exploit Scenarios

A user creates a delegate signature for recurring payments. Attacker creates a valid transaction and sets the CloseRemainderTo field to their address.

Examples

Note: This code contains several other vulnerabilities, see Rekeying, Unchecked Transaction Fees, Time-based Replay Attack.

def withdraw(
    duration,
    period,
    amount,
    receiver,
    timeout,
):
    return And(
        Txn.type_enum() == TxnType.Payment,
        Txn.first_valid() % period == Int(0),
        Txn.last_valid() == Txn.first_valid() + duration,
        Txn.receiver() == receiver,
        Txn.amount() == amount,
        Txn.first_valid() < timeout,
    )

Recommendations

Verify that the CloseRemainderTo field is set to the ZeroAddress or to any intended address before approving the transaction in the Teal contract.

Closing Asset

Lack of check for AssetCloseTo transaction field in smart signatures allows attackers to transfer the entire asset balance of the contract account or the delegator’s account to their account.

Description

Algorand supports Fungible and Non Fungible Tokens using Algorand Standard Assets(ASA). An Algorand account must first opti-in to the asset before that account can receive any tokens. Opting to an asset increases the minimum balance requirement of the account. Users can opt-out of the asset and decrease the minimum balance requirement using the AssetCloseTo field of Asset Transfer transaction. Setting the AssetCloseTo field transfers the account’s entire token balance remaining after transaction execution to the specified address.

Any user with access to the smart signature may construct and submit the transactions using the smart signature. The smart signatures approving asset transfer transactions have to ensure that the AssetCloseTo field is set to the ZeroAddress or any other specific address to avoid unintended transfer of tokens.

Exploit Scenarios

User creates a delegate signature that allows recurring transfers of a certain asset. Attacker creates a valid asset transfer transaction with AssetCloseTo field set to their address.

Examples

Note: This code contains several other vulnerabilities, see Rekeying, Unchecked Transaction Fees, Closing Asset, Time-based Replay Attack, Asset Id Check.

def withdraw_asset(
    duration,
    period,
    amount,
    receiver,
    timeout,
):
    return And(
        Txn.type_enum() == TxnType.AssetTransfer,
        Txn.first_valid() % period == Int(0),
        Txn.last_valid() == Txn.first_valid() + duration,
        Txn.asset_receiver() == receiver,
        Txn.asset_amount() == amount,
        Txn.first_valid() < timeout,
    )

Recommendations

Verify that the AssetCloseTo field is set to the ZeroAddress or to the intended address before approving the transaction in the Teal contract.

Group Size Check

Lack of group size check in contracts that are supposed to be called in an atomic group transaction might allow attackers to misuse the application.

Description

Algorand supports atomic transfers, an atomic transfer is a group of transactions that are submitted and processed as a single transaction. A group can contain upto 16 transactions and the group transaction fails if any of the included transactions fails. Algorand applications make use of group transactions to realize operations that may not be possible using a single transaction model. In such cases, it is necessary to check that the group transaction in itself is valid along with the individual transactions. One of the checks whose absence could be misused is group size check.

Exploit Scenarios

Application only checks that transactions at particular indices are meeting the criteria and performs the operations based on that. Attackers can create the transactions at the checked indices correctly and include equivalent application call transactions at all the remaining indices. Each application call executes successfully as every execution checks the same set of transactions. This results in performing operations multiple times, once for each application call. This could be damaging if those operations include funds or assets transfers among others.

Examples

Note: This code contains several other vulnerabilities, see Rekeying, Unchecked Transaction Fees, Closing Account, Time-based Replay Attack.

def split_and_withdraw(
    amount_1,
    receiver_1,
    amount_2,
    receiver_2,
    lock_expire_round,
):
    return And(
        Gtxn[0].type_enum() == TxnType.Payment,
        Gtxn[0].receiver() == receiver_1,
        Gtxn[0].amount() == amount_1,

        Gtxn[1].type_enum() == TxnType.Payment,
        Gtxn[1].receiver() == receiver_2,
        Gtxn[1].amount() == amount_2,

        Gtxn[0].first_valid == lock_expire_round,
    )

Recommendations

  • Verify that the group size of an atomic transfer is the intended size in the contracts.

  • Use Tealer to detect this issue.

  • Favor using ABI for smart contracts and relative indexes to verify the group transaction.

Time-based Replay Attack

Lack of check for lease field in smart signatures that intend to approve a single transaction in the particular period allows attackers to submit multiple valid transactions in that period.

Description

Algorand stops transaction replay attacks using a validity period. A validity period of a transaction is the sequence of blocks between FirstValid block and LastValid block. The transaction is considered valid only in that period and a transaction with the same hash can be processed only once in that period. Algorand also limits the period to a maximum of 1000 blocks. This allows the transaction creator to select the FirstValid, LastValid fields appropriately and feel assured that the transaction is processed only once in that period.

However, The same does not apply for transactions authorized by smart signatures. Even if the contract developer verifies the FirstValid and LastValid transaction fields to fixed values, an attacker can submit multiple transactions that are valid as per the contract. This is because any user can create and submit transactions authorized by a smart signature. The attacker can create transactions which have equal values for most transaction fields, for fields verified in the contract and slightly different values for the rest. Each one of these transactions will have a different hash and will be accepted by the protocol.

Exploit Scenarios

A user creates a delegate signature for recurring payments. Contract verifies the FirstValid and LastValid to only allow a single transaction each time. Attacker creates and submits multiple valid transactions with different hashes.

Examples

Note: This code contains several other vulnerabilities, see Rekeying, Unchecked Transaction Fees, Closing Account.

def withdraw(
    duration,
    period,
    amount,
    receiver,
    timeout,
):
    return And(
        Txn.type_enum() == TxnType.Payment,
        Txn.first_valid() % period == Int(0),
        Txn.last_valid() == Txn.first_valid() + duration,
        Txn.receiver() == receiver,
        Txn.amount() == amount,
        Txn.first_valid() < timeout,
    )

Recommendations

Verify that the Lease field of the transaction is set to a specific value. Lease enforces mutual exclusion, once a transaction with non-zero lease is confirmed by the protocol, no other transactions with same lease and sender will be accepted till the LastValid block

Access Controls

Lack of appropriate checks for application calls of type UpdateApplication and DeleteApplication allows attackers to update application’s code or delete an application entirely.

Description

When an application call is successful, additional operations are executed based on the OnComplete field. If the OnComplete field is set to UpdateApplication the approval and clear programs of the application are replaced with the programs specified in the transaction. Similarly, if the OnComplete field is set to DeleteApplication, application parameters are deleted. This allows attackers to update or delete the application if proper access controls are not enforced in the application.

Exploit Scenarios

A stateful contract serves as a liquidity pool for a pair of tokens. Users can deposit the tokens to get the liquidity tokens and can get back their funds with rewards through a burn operation. The contract does not enforce restrictions for UpdateApplication type application calls. Attacker updates the approval program with a malicious program that transfers all assets in the pool to the attacker's address.

Recommendations

  • Set proper access controls and apply various checks before approving applications calls of type UpdateApplication and DeleteApplication.

  • Use Tealer to detect this issue.

Asset Id Check

Lack of verification of asset id in the contract allows attackers to transfer a different asset in place of the expected asset and mislead the application.

Description

Contracts accepting and doing operations based on the assets transferred to the contract must verify that the transferred asset is the expected asset by checking the asset Id. Absence of check for expected asset Id could allow attackers to manipulate contract’s logic by transferring a fake, less or more valuable asset instead of the correct asset.

Exploit Scenarios

  • A liquidity pool contract mints liquidity tokens on deposit of two tokens. Contract does not check that the asset Ids in the two asset transfer transactions are correct. Attacker deposits the same less valuable asset in the two transactions and withdraws both tokens by burning the pool tokens.
  • User creates a delegate signature that allows recurring transfers of a certain asset. Attacker creates a valid asset transfer transaction of more valuable assets.

Examples

Note: This code contains several other vulnerabilities, see Rekeying, Unchecked Transaction Fees, Closing Asset, Time-based Replay Attack.

def withdraw_asset(
    duration,
    period,
    amount,
    receiver,
    timeout,
):
    return And(
        Txn.type_enum() == TxnType.AssetTransfer,
        Txn.first_valid() % period == Int(0),
        Txn.last_valid() == Txn.first_valid() + duration,
        Txn.asset_receiver() == receiver,
        Txn.asset_amount() == amount,
        Txn.first_valid() < timeout,
    )

Recommendations

Verify the asset id to be expected asset for all asset related operations in the contract.

Denial of Service

When a contract does not verify whether an account has opted in to an asset and attempts to transfer that asset, an attacker can DoS other users if the contract's operation is to transfer asset to multiple accounts.

Description

A user must explicitly opt-in to receive any particular Algorand Standard Asset(ASAs). A user may also opt out of an ASA. A transaction will fail if it attempts to transfer tokens to an account that didn’t opt in to that asset. This could be leveraged by attackers to DOS a contract if the contract’s operation depends on successful transfer of an asset to the attacker owned address.

Exploit Scenarios

Contract attempts to transfer assets to multiple users. One user is not opted in to the asset. The transfer operation fails for all users.

Examples

Note: This code contains several other vulnerabilities, see Rekeying, Unchecked Transaction Fees, Closing Asset, Group Size Check, Time-based Replay Attack, Asset Id Check

def split_and_withdraw_asset(
    amount_1,
    receiver_1,
    amount_2,
    receiver_2,
    lock_expire_round,
):
    return And(
        Gtxn[0].type_enum() == TxnType.AssetTransfer,
        Gtxn[0].asset_receiver() == receiver_1,
        Gtxn[0].asset_amount() == amount_1,

        Gtxn[1].type_enum() == TxnType.AssetTransfer,
        Gtxn[1].receiver() == receiver_2,
        Gtxn[1].amount() == amount_2,

        Gtxn[0].first_valid == lock_expire_round,
    )

Recommendations

Use pull over push pattern for transferring assets to users.

Inner Transaction Fee

Inner transaction fees are by default set to an estimated amount which are deducted from the application account if it is the Sender. An attacker can perform operations executing inner transactions and drain system funds, making it under-collateralized.

Description

Inner transactions are initialized with Sender set to the application account and Fee to the minimum allowable, taking into account MinTxnFee and credit from overpaying in earlier transactions. The inner transaction fee depends on the transaction fee paid by the user. As a result, the user controls, to some extent, the fee paid by the application.

If the application does not explicitly set the Fee to zero, an attacker can burn the application’s balance in the form of fees. This also becomes an issue if the application implements internal bookkeeping to track the application balance and does not account for fees.

Exploit Scenarios

@router.method(no_op=CallConfig.CALL)
def mint(pay: abi.PaymentTransaction) -> Expr:
    return Seq([
            # perform validations and other operations
            # [...]
            # mint wrapped-asset id
            InnerTxnBuilder.Begin(),
            InnerTxnBuilder.SetFields(
                {
                    TxnField.type_enum: TxnType.AssetTransfer,
                    TxnField.asset_receiver: Txn.sender(),
                    TxnField.xfer_asset: wrapped_algo_asset_id,
                    TxnField.asset_amount: pay.get().amount(),
                }
            ),
            InnerTxnBuilder.Submit(),
            # [...]
    ])

The application does not explicitly set the inner transaction fee to zero. When user mints wrapped-algo, some of the ALGO is burned in the form of fees. The amount of wrapped-algo in circulation will be greater than the application ALGO balance. The system will be under-collateralized.

Recommendations

Explicitly set the inner transaction fees to zero and use the fee pooling feature of the Algorand.

Clear State Transaction Check

The lack of checks on the OnComplete field of the application calls might allow an attacker to execute the clear state program instead of the approval program, breaking core validations.

Description

Algorand applications make use of group transactions to realize operations that may not be possible using a single transaction model. Some operations require that other transactions in the group call certain methods and applications. These requirements are asserted by validating that the transactions are ApplicationCall transactions. However, the OnComplete field of these transactions is not always validated, allowing an attacker to submit ClearState ApplicationCall transactions. The ClearState transaction invokes the clear state program instead of the intended approval program of the application.

Exploit Scenario

A protocol offers flash loans from a liquidity pool. The flash loan operation is implemented using two methods: take_flash_loan and pay_flash_loan. take_flash_loan method transfers the assets to the user and pay_flash_loan verifies that the user has returned the borrowed assets. take_flash_loan verifies that a later transaction in the group calls the pay_flash_loan method. However, It does not validate the OnComplete field.

@router.method(no_op=CallConfig.CALL)
def take_flash_loan(offset: abi.Uint64, amount: abi.Uint64) -> Expr:
    return Seq([
        # Ensure the pay_flash_loan method is called
        Assert(And(
            Gtxn[Txn.group_index() + offset.get()].type_enum == TxnType.ApplicationCall,
            Gtxn[Txn.group_index() + offset.get()].application_id() == Global.current_application_id(),
            Gtxn[Txn.group_index() + offset.get()].application_args[0] == MethodSignature("pay_flash_loan(uint64)")
        )),
        # Perform other validations, transfer assets to the user, update the global state
        # [...]
    ])

@router.method(no_op=CallConfig.CALL)
def pay_flash_loan(offset: abi.Uint64) -> Expr:
    return Seq([
        # Validate the "take_flash_loan" transaction at `Txn.group_index() - offset.get()`
        # Ensure the user has returned the funds to the pool along with the fee. Fail the transaction otherwise
        # [...]
    ])

An attacker constructs a valid group transaction for flash loan but sets the OnComplete field of pay_flash_loan call to ClearState. The clear state program is executed for complete_flash_loan call, which does not validate that the attacker has returned the funds. The attacker steals all the assets in the pool.

Recommendations

Validate the OnComplete field of the ApplicationCall transactions.

(Not So) Smart Contracts

This repository contains examples of common Cairo smart contract vulnerabilities, featuring code from real smart contracts. Utilize the Not So Smart Contracts to learn about Cairo vulnerabilities, refer to them during security reviews, and use them as a benchmark for security analysis tools.

Features

Each Not So Smart Contract consists of a standard set of information:

  • Vulnerability type description
  • Attack scenarios to exploit the vulnerability
  • Recommendations to eliminate or mitigate the vulnerability
  • Real-world contracts exhibiting the flaw
  • References to third-party resources providing more information

Vulnerabilities

Not So Smart ContractDescription
Arithmetic overflowInsecure arithmetic in Cairo for the felt252 type
L1 to L2 Address ConversionEssential L2 address checks for L1 to L2 messaging
L1 to L2 message failureMessages sent from L1 may not be processed by the sequencer
Overconstrained L1 <-> L2 interactionAsymmetrical checks on the L1 or L2 side can cause a DOS
Signature replaysNecessary robust reuse protection due to account abstraction
Unchecked from address in L1 HandlerAccess control issue when sending messages from L1 to L2

Credits

These examples are developed and maintained by Trail of Bits.

If you have any questions, issues, or wish to learn more, join the #ethereum channel on the Empire Hacking Slack or contact us directly.

Arithmetic Overflow with Felt Type

The default primitive type, the field element (felt), behaves much like an integer in other languages, but there are a few important differences to keep in mind. A felt can be interpreted as an unsigned integer in the range [0, P], where P, a 252 bit prime, represents the order of the field used by Cairo. Arithmetic operations using felts are unchecked for overflow or underflow, which can lead to unexpected results if not properly accounted for. Do note that Cairo's builtin primitives for unsigned integers are overflow/underflow safe and will revert.

Example

The following simplified code highlights the risk of felt underflow. The check_balance function is used to validate if a user has a large enough balance. However, the check is faulty because passing an amount such that amt > balance will underflow and the check will be true.


struct Storage {
    balances: LegacyMap<ContractAddress, felt252>
}

fn check_balance(self: @ContractState, amt: felt252) {
    let caller = get_caller_address();
    let balance = self.balances.read(caller);
    assert(balance - amt >= 0);
}

Mitigations

  • Always add checks for overflow when working with felts directly.
  • Use the default signed integer types unless a felt is explicitly required.
  • Consider using Caracal, as it comes with a detector for checking potential overflows when doing felt252 arithmetic operaitons.

L1 to L2 Address Conversion

In Starknet, addresses are of the felt type, while on L1 addresses are of the uint160 type. To pass address types during cross-layer messaging, the address variable is typically given as a uint256. However, this may create an issue where an address on L1 maps to the zero address (or an unexpected address) on L2. This is because the primitive type in Cairo is the felt, which lies within the range 0 < x < P, where P is the prime order of the curve. Usually, we have P = 2^251 + 17 * 2^192 + 1.

Example

Consider the following code to initiate L2 deposits from L1. The first example has no checks on the to parameter, and depending on the user's address, it could transfer tokens to an unexpected address on L2. The second example, however, adds verification to ensure this does not happen. Note that the code is a simplified version of how messages are sent on L1 and processed on L2. For a more comprehensive overview, see here: https://www.cairo-lang.org/docs/hello_starknet/l1l2.html.

contract L1ToL2Bridge {
    uint256 public constant STARKNET_FIELD_PRIME; // the prime order P of the elliptic curve used
    IERC20 public constant token; // some token to deposit on L2

    event Deposited(uint256 to, uint256 amount);

    function badDepositToL2(uint256 to, uint256 amount) public returns (bool) {
        token.transferFrom(msg.sender, address(this), amount);
        emit Deposited(to, amount); // this message gets processed on L2
        return true;
    }

    function betterDepositToL2(uint256 to, uint256 amount) public returns (bool) {
        require(to != 0 && to < STARKNET_FIELD_PRIME, "invalid address"); // verifies 0 < to < P
        token.transferFrom(msg.sender, address(this), amount);
        emit Deposited(to, amount); // this message gets processed on L2
        return true;
    }
}

Mitigations

When sending a message from L1 to L2, ensure verification of parameters, particularly user-supplied ones. Keep in mind that Cairo's default felt type range is smaller than the uint256 type used by Solidity.

L1 to L2 Message Failure

In Starknet, Ethereum contracts can send messages from L1 to L2 using a bridge. However, it is not guaranteed that the message will be processed by the sequencer. For instance, a message can fail to be processed if there is a sudden spike in the gas price and the value provided is too low. To address this issue, Starknet developers have provided an API to cancel ongoing messages.

Example

Consider the following code to initiate L2 deposits from L1, taking the tokens from the user:

contract L1ToL2Bridge {
    IERC20 public token; // some token to deposit on L2

    function depositToL2(address to, uint256 amount) public returns (bool) {
        require(token.transferFrom(msg.sender, address(this), amount));
        // ...
        StarknetCore.sendMessageToL2(data);
        return true;
    }
}

If an L1 message is never processed by the sequencer, users will never receive their tokens in either L1 or L2, and they need a way to cancel the message.

A recent AAVE audit highlighted this issue and required the addition of code to cancel messages.

Mitigations

When sending a message from L1 to L2, it is essential to consider the possibility that a message may never be processed by the sequencer. This can block either the contract from reaching a certain state or users from retrieving their funds. If needed, allow the use of startL1ToL2MessageCancellation and cancelL1ToL2Message to cancel ongoing messages.

Overconstrained L1 <-> L2 interaction

When interacting with contracts that are designed to interact with both L1 and L2, care must be taken that the checks and validations on both sides are symmetrical. If one side has more validations than the other, this could create a situation where a user performs an action on one side, but is unable to perform the corresponding action on the other side, leading to a loss of funds or a denial of service.

Example

The following Starknet bridge contract allows for permissionless deposit to any address on L1 via the deposit_to_L1 function. In particular, someone can deposit tokens to the BAD_ADDRESS. However, in that case the tokens will be lost forever, because the tokens are burned on L2 and the L1 contract's depositFromL2 function prevents BAD_ADDRESS from being the recipient.

#[storage]
struct Storage {
    l1_bridge: EthAddress,
    balances: LegacyMap<ContractAddress,u256>
}

#[derive(Serde)]
struct Deposit {
    recipient: EthAddress,
    token: EthAddress,
    amount: u256
}

fn deposit_to_l1(ref self: ContractState, deposit: Deposit) {
    let caller = get_caller_address();
    //burn the tokens on the L2 side
    self.balances.write(caller, self.balances.read(caller) - deposit.amount);
    let payload = ArrayTrait::new();
    starknet::send_message_to_l1_syscall(self.l1_bridge.read(), deposit.serialize(ref payload)).unwrap();
}

address public immutable MESSENGER_CONTRACT;
address public immutable L2_TOKEN_BRIDGE;
address public constant BAD_ADDRESS = address(0xdead);

constructor(address _messenger, address _bridge) {
    MESSENGER_CONTRACT = _messenger;
    L2_TOKEN_BRIDGE = _bridge;
}

function depositFromL2(address recipient, address token, uint256 amount) external {
    require(recipient != BAD_ADDRESS, "blacklisted");
    uint256[] memory payload = _buildPayload(recipient,token,amount);
    MESSENGER_CONTRACT.consumeMessageFromL2(L2_TOKEN_BRIDGE,payload);
    //deposit logic
    [...]
}

function _buildPayload(address recipient, address token, uint256 amount) internal returns (uint256[] memory) {
    //payload building logic for Starknet message
    [...]
}

Mitigations

  • Make sure to validate that the checks on both the L1 and L2 side are similar enough to prevent unexpected behavior. Ensure that any unsymmetric validations on either side cannot lead to a tokens being trapped or any other denial of service.

Signature Replay Protection

The StarkNet account abstraction model enables offloading many authentication details to contracts, providing a higher degree of flexibility. However, this also means that signature schemes must be designed with great care. Signatures should be resistant to replay attacks and signature malleability. They must include a nonce and preferably have a domain separator to bind the signature to a specific contract and chain. For instance, this prevents testnet signatures from being replayed against mainnet contracts.

Example

Consider the following function that validates a signature for EIP712-style permit functionality. Notice that the contract lacks a way of keeping track of nonces. As a result, the same signature can be replayed over and over again. In addition, there is no method for identifying the specific chain a signature is designed for. Consequently, this signature schema would allow signatures to be replayed both on the same chain and across different chains, such as between a testnet and mainnet.

    #[storage]
    struct Storage {
        authorized_pubkey: felt252
    }

    #[derive(Hash)]
    struct Signature {
        sig_r: felt252,
        sig_s: felt252,
        amount: u256,
        recipient: ContractAddress
    }

    fn bad_is_valid_signature(self: @ContractState, sig: Signature) {
        let hasher = PoseidonTrait::new();
        let hash = hasher.update_with(sig).finalize();
        ecdsa::check_ecdsa_signature(hash,authorized_pubkey,sig.r,sig.s);
    }

Mitigations

Unchecked from address in L1 Handler function

A function with the l1_handler annotation is intended to be called from L1. The first parameter of the l1_handler function is always from, which represents the msg.sender of the L1 transaction that attempted to invoke the function on Starknet. If the l1_handler function is designed to be invoked from a specific address on mainnet, not checking the from address may allow anyone to call the function, opening up access control vulnerabilities.

Example

The following Starknet bridge contract's owner, specified in the uint256[] calldata payload array, is designed to be called only from the setOwnerOnL2() function. Even though the owner is checked on the solidity side, the lack of validation of the from_address parameter allows anyone to call the function from an arbitrary L1 contract, becoming the owner of the bridge on L2.

address public immutable OWNER;
address public immutable MESSENGER_CONTRACT;
address public immutable L2_BRIDGE_ADDRESS;
constructor(address _owner, address _messenger, address _bridge) {
    OWNER = _owner;
    MESSENGER_CONTRACT = _messenger;
    L2_BRIDGE_ADDRESS = _bridge;

}

function setOwnerOnL2(uint256[] calldata payload, uint256 selector) external {
    require(owner == msg.sender, "not owner");
    IStarknetMessaging(MESSENGER_CONTRACT).sendMessageToL2(L2_BRIDGE_ADDRESS, selector, payload);
}
#[storage]
struct Storage {
    owner: ContractAddress
}

#[l1_handler]
fn set_owner_from_l1(ref self: ContractState, from_address: felt252, new_owner: ContractAddress) {
    self.owner.write(new_owner);
}

Mitigations

  • Make sure to validate the from_address, otherwise any L1 contract can invoke the annotated Starknet function.
  • Consider using Caracal, as it comes with a detector for verifying if the from_address is unchecked in an l1_handler function.

(Not So) Smart Cosmos

This repository contains examples of common Cosmos applications vulnerabilities, including code from real applications. Use Not So Smart Cosmos to learn about Cosmos (Tendermint) vulnerabilities, as a reference when performing security reviews, and as a benchmark for security and analysis tools.

Features

Each Not So Smart Cosmos includes a standard set of information:

  • Description of the vulnerability type
  • Attack scenarios to exploit the vulnerability
  • Recommendations to eliminate or mitigate the vulnerability
  • Real-world contracts that exhibit the flaw
  • References to third-party resources with more information

Vulnerabilities

Not So Smart ContractDescription
Incorrect signersBroken access controls due to incorrect signers validation
Non-determinismConsensus failure because of non-determinism
Not prioritized messagesRisks arising from usage of not prioritized message types
Slow ABCI methodsConsensus failure because of slow ABCI methods
ABCI methods panicChain halt due to panics in ABCI methods
Broken bookkeepingExploit mismatch between different modules' views on balances
Rounding errorsBugs related to imprecision of finite precision arithmetic
Unregistered message handlerBroken functionality because of unregistered msg handler
Missing error handlerMissing error handling leads to successful execution of a transaction that should have failed

Credits

These examples are developed and maintained by Trail of Bits.

If you have questions, problems, or just want to learn more, then join the #ethereum channel on the Empire Hacking Slack or contact us directly.

Incorrect Signers

In Cosmos, transaction's signature(s) are validated against public keys (addresses) taken from the transaction itself, where locations of the keys are specified in GetSigners methods.

In the simplest case there is just one signer required, and its address is simple to use correctly. However, in more complex scenarios like when multiple signatures are required or a delegation schema is implemented, it is possible to make mistakes about what addresses in the transaction (the message) are actually authenticated.

Fortunately, mistakes in GetSigners should make part of application's intended functionality not working, making it easy to spot the bug.

Example

The example application allows an author to create posts. A post can be created with a MsgCreatePost message, which has signer and author fields.

service Msg {
      rpc CreatePost(MsgCreatePost) returns (MsgCreatePostResponse);
}

message MsgCreatePost {
  string signer = 1;
  string author = 2;
  string title = 3;
  string body = 4;
}

message MsgCreatePostResponse {
  uint64 id = 1;
}

message Post {
  string author = 1;
  uint64 id = 2;
  string title = 3;
  string body = 4;
}

The signer field is used for signature verification - as can be seen in GetSigners method below.

func (msg *MsgCreatePost) GetSigners() []sdk.AccAddress {
    signer, err := sdk.AccAddressFromBech32(msg.Signer)
    if err != nil {
        panic(err)
    }
    return []sdk.AccAddress{Signer}
}

func (msg *MsgCreatePost) GetSignBytes() []byte {
    bz := ModuleCdc.MustMarshalJSON(msg)
    return sdk.MustSortJSON(bz)
}

func (msg *MsgCreatePost) ValidateBasic() error {
    _, err := sdk.AccAddressFromBech32(msg.Signer)
    if err != nil {
        return sdkerrors.Wrapf(sdkerrors.ErrInvalidAddress, "invalid creator address (%s)", err)
    }
    return nil
}

The author field is saved along with the post's content:

func (k msgServer) CreatePost(goCtx context.Context, msg *types.MsgCreatePost) (*types.MsgCreatePostResponse, error) {
    ctx := sdk.UnwrapSDKContext(goCtx)

    var post = types.Post{
        Author: msg.Author,
        Title:  msg.Title,
        Body:   msg.Body,
    }

    id := k.AppendPost(ctx, post)

    return &types.MsgCreatePostResponse{Id: id}, nil
}

The bug here - mismatch between the message signer address and the stored address - allows users to impersonate other users by sending an arbitrary author field.

Mitigations

  • Keep signers-related logic simple
  • Implement basic sanity tests for all functionalities

Non-determinism

Non-determinism in conensus-relevant code will cause the blockchain to halt. There are quite a few sources of non-determinism, some of which are specific to the Go language:

Example

Below we can see an iteration over a amounts map. If k.GetPool fails for more than one asset, then different nodes will fail with different errors, causing the chain to halt.

func (k msgServer) CheckAmounts(goCtx context.Context, msg *types.MsgCheckAmounts) (*types.MsgCheckAmountsResponse, error) {
    ctx := sdk.UnwrapSDKContext(goCtx)

    amounts := make(map[Asset]int)
    for asset, coin := range allMoney.Coins {
        amounts[asset] = Compute(coin)
    }

    total int := 0
    for asset, f := range amounts {
        poolSize, err := k.GetPool(ctx, asset, f)
        if err != nil {
            return nil, err
        }
        total += poolSize
    }

    if total == 0 {
        return nil, errors.New("Zero total")
    }

    return &types.MsgCheckAmountsResponse{}, nil
}

Even if we fix the map problem, it is still possible that the total overflows for nodes running on 32-bit architectures earlier than for the rest of the nodes, again causing the chain split.

Mitigations

  • Use static analysis, for example custom CodeQL rules
  • Test your application with nodes running on various architectures or require nodes to run on a specific one
  • Prepare and test procedures for recovering from a blockchain split

External examples

Not prioritized messages

Some message types may be more important than others and should have priority over them. That is, the more significant a message type is, the more quickly it should be included in a block, before other messages are.

Failing to prioritize message types allows attackers to front-run them, possibly gaining unfair advantage. Moreover, during high network congestion, the message may be simply not included in a block for a long period, causing the system to malfunction.

In the Cosmos's mempool, transactions are ordered in first-in-first-out (FIFO) manner by default. Especially, there is no fee-based ordering.

Example

An example application implements a lending platform. It uses a price oracle mechanism, where privileged entities can vote on new assets' prices. The mechanism is implemented as standard messages.

service Msg {
  rpc Lend(MsgLend) returns (MsgLendResponse);

  rpc Borrow(MsgBorrow) returns (MsgBorrowResponse);

  rpc Liquidate(MsgLiquidate) returns (MsgLiquidateResponse);

  rpc OracleCommitPrice(MsgOracleCommitPrice) returns (MsgOracleCommitPriceResponse);

  rpc OracleRevealPrice(MsgOracleRevealPrice) returns (MsgOracleRevealPriceResponse);
}

Prices ought to be updated (committed and revealed) after every voting period. However, an attacker can spam the network with low-cost transactions to completely fill blocks, leaving no space for price updates. He can then profit from the fact that the system uses outdated, stale prices.

Example 2

Lets consider a liquidity pool application that implements the following message types:

service Msg {
  rpc CreatePool(MsgCreatePool) returns (MsgCreatePoolResponse);

  rpc Deposit(MsgDeposit) returns (MsgDepositResponse);

  rpc Withdraw(MsgWithdraw) returns (MsgWithdrawResponse);

  rpc Swap(MsgSwap) returns (MsgSwapResponse);

  rpc Pause(MsgPause) returns (MsgPauseResponse);

  rpc Resume(MsgResume) returns (MsgResumeResponse);
}

There is the Pause message, which allows privileged users to stop the pool.

Once a bug in pool's implementation is discovered, attackers and the pool's operators will compete for whose message is first executed (Swap vs Pause). Prioritizing Pause messages will help pool's operators to prevent exploitation, but in this case it won't stop the attackers completely. They can outrun the Pause message by order of magnitude - so the priority will not matter - or even cooperate with a malicious validator node - who can order his mempool in an arbitrary way.

Mitigations

  • Use CheckTx's priority return value to prioritize messages. Please note that this feature has a transaction (not a message) granularity - users can send multiple messages in a single transaction, and it is the transaction that will have to be prioritized.
  • Perform authorization for prioritized transactions as early as possible. That is, during the CheckTx phase. This will prevent attackers from filling whole blocks with invalid, but prioritized transactions. In other words, implement a mechanism that will prevent validators from accepting not-authorized, prioritized messages into a mempool.
  • Alternatively, charge a high fee for prioritized transactions to disincentivize attackers.

External examples

Slow ABCI methods

ABCI methods (like EndBlocker) are not constrained by gas. Therefore, it is essential to ensure that they always will finish in a reasonable time. Otherwise, the chain will halt.

Example

Below you can find part of a tokens lending application. Before a block is executed, the BeginBlocker method charges an interest for each borrower.

func BeginBlocker(ctx sdk.Context, k keeper.Keeper) {
    updatePrices(ctx, k)
    accrueInterest(ctx, k)
}

func accrueInterest(ctx sdk.Context, k keeper.Keeper) {
    for _, pool := range k.GetLendingPools() {
        poolAssets := k.GetPoolAssets(ctx, pool.Id)
        for userId, _ := range k.GetAllUsers() {
            for _, asset := range poolAssets {
                for _, loan := range k.GetUserLoans(ctx, pool, asset, userId) {
                    if err := k.AccrueInterest(ctx, loan); err != nil {
                        k.PunishUser(ctx, userId)
                    }
                }
            }
        }
    }
}

The accrueInterest contains multiple nested for loops and is obviously too complex to be efficient. Mischievous users can take a lot of small loans to slow down computation to a point where the chain is not able to keep up with blocks production and halts.

Mitigations

External examples

ABCI methods panic

A panic inside an ABCI method (e.g., EndBlocker) will stop the chain. There should be no unanticipated panics in these methods.

Some less expected panic sources are:

Example

The application below enforces limits on how much coins can be borrowed globally. If the loan.Borrowed array of Coins can be forced to be not-sorted (by coins' denominations), the Add method will panic.

Moreover, the Mul may panic if some asset's price becomes large.

func BeginBlocker(ctx sdk.Context, k keeper.Keeper) {
    if !validateTotalBorrows(ctx, k) {
        k.PauseNewLoans(ctx)
    }
}

func validateTotalBorrows(ctx sdk.Context, k keeper.Keeper) {
    total := sdk.NewCoins()
    for _, loan := range k.GetUsersLoans() {
        total.Add(loan.Borrowed...)
    }

    for _, totalOneAsset := range total {
        if totalOneAsset.Amount.Mul(k.GetASsetPrice(totalOneAsset.Denom)).GTE(k.GetGlobalMaxBorrow()) {
            return false
        }
    }
    return true
}

Mitigations

External examples

Broken Bookkeeping

The x/bank module is the standard way to manage tokens in a cosmos-sdk based applications. The module allows to mint, burn, and transfer coins between both users' and modules' accounts. If an application implements its own, internal bookkeeping, it must carefully use the x/bank's features.

Example

An application enforces the following invariant as a sanity-check: amount of tokens owned by a module equals to the amount of tokens deposited by users via the custom x/hodl module.

func BalanceInvariant(k Keeper) sdk.Invariant {
    return func(ctx sdk.Context) (string, bool) {
        weAreFine := true
        msg := "hodling hard"

        weHold := k.bankKeeper.SpendableCoins(authtypes.NewModuleAddress(types.ModuleName)).AmountOf("BTC")
        usersDeposited := k.GetTotalDeposited("BTC")

        if weHold != usersDeposited {
            msg = fmt.Sprintf("%dBTC missing! Halting chain.\n", usersDeposited - weHold)
            weAreFine = false
        }

        return sdk.FormatInvariant(types.ModuleName, "hodl-balance",), weAreFine
    }
}

A spiteful user can simply transfer a tiny amount of BTC tokens directly to the x/hodl module via a message to the x/bank module. That would bypass accounting of the x/hodl, so the GetTotalDeposited function would report a not-updated amount, smaller than the module's SpendableCoins.

Because an invariant's failure stops the chain, the bug constitutes a simple Denial-of-Service attack vector.

Example 2

An example application implements a lending platform. It allows users to deposit Tokens in exchange for xTokens - similarly to the Compound's cTokens. Token:xToken exchange rate is calculated as (amount of Tokens borrowed + amount of Tokens held by the module account) / (amount of uTokens in circulation).

Implementation of the GetExchangeRate method computing an exchange rate is presented below.

func (k Keeper) GetExchangeRate(tokenDenom string) sdk.Coin {
    uTokenDenom := createUDenom(tokenDenom)

    tokensHeld := k.bankKeeper.SpendableCoins(authtypes.NewModuleAddress(types.ModuleName)).AmountOf(tokenDenom).ToDec()
    tokensBorrowed := k.GetTotalBorrowed(tokenDenom)
    uTokensInCirculation := k.bankKeeper.GetSupply(uTokenDenom).Amount

    return (tokensHeld + tokensBorrowed) / uTokensInCirculation
}

A malicious user can screw an exchange rate in two ways:

  • by force-sending Tokens to the module, changing the tokensHeld value
  • by transferring uTokens to another chain via IBC, chaning uTokensInCirculation value

The first "attack" could be pulled of by sending MsgSend message. However, it would be not profitable (probably), as executing it would irreversibly decrease an attacker's resources.

The second one works because the IBC module burns transferred coins in the source chain and mints corresponding tokens in the destination chain. Therefore, it will decrease the supply reported by the x/bank module, increasing the exchange rate. After the attack the malicious user can just transfer back uTokens.

Mitigations

External examples

Rounding errors

Application developers must take care of correct rounding of numbers, especially if the rounding impacts tokens amounts.

Cosmos-sdk offers two custom types for dealing with numbers:

  • sdk.Int (sdk.UInt) type for integral numbers
  • sdk.Dec type for decimal arithmetic

The sdk.Dec type has problems with precision and does not guarantee associativity, so it must be used carefully. But even if a more robust library for decimal numbers is deployed in the cosmos-sdk, rounding may be unavoidable.

Example

Below we see a simple example demonstrating sdk.Dec type's precision problems.

func TestDec() {
    a := sdk.MustNewDecFromStr("10")
    b := sdk.MustNewDecFromStr("1000000010")
    x := a.Quo(b).Mul(b)
    fmt.Println(x)  // 9.999999999999999000

    q := float32(10)
    w := float32(1000000010)
    y := (q / w) * w
    fmt.Println(y)  // 10
}

Mitigations

  • Ensure that all tokens operations that must round results always benefit the system (application) and not users. In other words, always decide on the correct rounding direction. See Appendix G in the Umee audit report

  • Apply "multiplication before division" pattern. That is, instead of computing (x / y) * z do (x * z) / y

  • Observe issue #11783 for a replacement of the sdk.Dec type

External examples

Unregistered message handler

In the legacy version of the Msg Service, all messages have to be registered in a module keeper's NewHandler method. Failing to do so would prevent users from sending the not-registered message.

In the recent Cosmos version manual registration is no longer needed.

Example

There is one message handler missing.

service Msg {
  rpc ConfirmBatch(MsgConfirmBatch) returns (MsgConfirmBatchResponse) {
      option (google.api.http).post = "/gravity/v1/confirm_batch";
  }
  rpc UpdateCall(MsgUpdateCall) returns (MsgUpdateCallResponse) {
      option (google.api.http).post = "/gravity/v1/update_call";
  }
  rpc CancelCall(MsgCancelCall) returns (MsgCancelCallResponse) {
      option (google.api.http).post = "/gravity/v1/cancel_call";
  }
  rpc SetCall(MsgSetCall) returns (MsgSetCallResponse) {
      option (google.api.http).post = "/gravity/v1/set_call";
  }
  rpc SendCall(MsgSendCall) returns (MsgSendCallResponse) {
      option (google.api.http).post = "/gravity/v1/send_call";
  }
  rpc SetUserAddress(MsgSetUserAddress) returns (MsgSetUserAddressResponse) {
      option (google.api.http).post = "/gravity/v1/set_useraddress";
  }
  rpc SendUserAddress(MsgSendUserAddress) returns (MsgSendUserAddressResponse) {
      option (google.api.http).post = "/gravity/v1/send_useraddress";
  }
  rpc RequestBatch(MsgRequestBatch) returns (MsgRequestBatchResponse) {
      option (google.api.http).post = "/gravity/v1/request_batch";
  }
  rpc RequestCall(MsgRequestCall) returns (MsgRequestCallResponse) {
      option (google.api.http).post = "/gravity/v1/request_call";
  }
  rpc RequestUserAddress(MsgRequestUserAddress) returns (MsgRequestUserAddressResponse) {
      option (google.api.http).post = "/gravity/v1/request_useraddress";
  }
  rpc ConfirmEthClaim(MsgConfirmEthClaim) returns (MsgConfirmEthClaimResponse) {
      option (google.api.http).post = "/gravity/v1/confirm_ethclaim";
  }
  rpc UpdateEthClaim(MsgUpdateEthClaim) returns (MsgUpdateEthClaimResponse) {
      option (google.api.http).post = "/gravity/v1/update_ethclaim";
  }
  rpc SetBatch(MsgSetBatch) returns (MsgSetBatchResponse) {
      option (google.api.http).post = "/gravity/v1/set_batch";
  }
  rpc SendBatch(MsgSendBatch) returns (MsgSendBatchResponse) {
      option (google.api.http).post = "/gravity/v1/send_batch";
  }
  rpc CancelUserAddress(MsgCancelUserAddress) returns (MsgCancelUserAddressResponse) {
      option (google.api.http).post = "/gravity/v1/cancel_useraddress";
  }
  rpc CancelEthClaim(MsgCancelEthClaim) returns (MsgCancelEthClaimResponse) {
      option (google.api.http).post = "/gravity/v1/cancel_ethclaim";
  }
  rpc RequestEthClaim(MsgRequestEthClaim) returns (MsgRequestEthClaimResponse) {
      option (google.api.http).post = "/gravity/v1/request_ethclaim";
  }
  rpc UpdateBatch(MsgUpdateBatch) returns (MsgUpdateBatchResponse) {
      option (google.api.http).post = "/gravity/v1/update_batch";
  }
  rpc SendEthClaim(MsgSendEthClaim) returns (MsgSendEthClaimResponse) {
      option (google.api.http).post = "/gravity/v1/send_ethclaim";
  }
  rpc SetEthClaim(MsgSetEthClaim) returns (MsgSetEthClaimResponse) {
      option (google.api.http).post = "/gravity/v1/set_ethclaim";
  }
  rpc CancelBatch(MsgCancelBatch) returns (MsgCancelBatchResponse) {
      option (google.api.http).post = "/gravity/v1/cancel_batch";
  }
  rpc UpdateUserAddress(MsgUpdateUserAddress) returns (MsgUpdateUserAddressResponse) {
      option (google.api.http).post = "/gravity/v1/update_useraddress";
  }
  rpc ConfirmCall(MsgConfirmCall) returns (MsgConfirmCallResponse) {
      option (google.api.http).post = "/gravity/v1/confirm_call";
  }
  rpc ConfirmUserAddress(MsgConfirmUserAddress) returns (MsgConfirmUserAddressResponse) {
      option (google.api.http).post = "/gravity/v1/confirm_useraddress";
  }
}
func NewHandler(k keeper.Keeper) sdk.Handler {
    msgServer := keeper.NewMsgServerImpl(k)

    return func(ctx sdk.Context, msg sdk.Msg) (*sdk.Result, error) {
        ctx = ctx.WithEventManager(sdk.NewEventManager())
        switch msg := msg.(type) {
        case *types.MsgSetBatch:
            res, err := msgServer.SetBatch(sdk.WrapSDKContext(ctx), msg)
            return sdk.WrapServiceResult(ctx, res, err)
        case *types.MsgUpdateUserAddress:
            res, err := msgServer.UpdateUserAddress(sdk.WrapSDKContext(ctx), msg)
            return sdk.WrapServiceResult(ctx, res, err)
        case *types.MsgUpdateCall:
            res, err := msgServer.UpdateCall(sdk.WrapSDKContext(ctx), msg)
            return sdk.WrapServiceResult(ctx, res, err)
        case *types.MsgSendBatch:
            res, err := msgServer.SendBatch(sdk.WrapSDKContext(ctx), msg)
            return sdk.WrapServiceResult(ctx, res, err)
        case *types.MsgCancelUserAddress:
            res, err := msgServer.CancelUserAddress(sdk.WrapSDKContext(ctx), msg)
            return sdk.WrapServiceResult(ctx, res, err)
        case *types.MsgRequestBatch:
            res, err := msgServer.RequestBatch(sdk.WrapSDKContext(ctx), msg)
            return sdk.WrapServiceResult(ctx, res, err)
        case *types.MsgUpdateEthClaim:
            res, err := msgServer.UpdateEthClaim(sdk.WrapSDKContext(ctx), msg)
            return sdk.WrapServiceResult(ctx, res, err)
        case *types.MsgSendCall:
            res, err := msgServer.SendCall(sdk.WrapSDKContext(ctx), msg)
            return sdk.WrapServiceResult(ctx, res, err)
        case *types.MsgSetCall:
            res, err := msgServer.SetCall(sdk.WrapSDKContext(ctx), msg)
            return sdk.WrapServiceResult(ctx, res, err)
        case *types.MsgCancelEthClaim:
            res, err := msgServer.CancelEthClaim(sdk.WrapSDKContext(ctx), msg)
            return sdk.WrapServiceResult(ctx, res, err)
        case *types.MsgConfirmEthClaim:
            res, err := msgServer.ConfirmEthClaim(sdk.WrapSDKContext(ctx), msg)
            return sdk.WrapServiceResult(ctx, res, err)
        case *types.MsgConfirmCall:
            res, err := msgServer.ConfirmCall(sdk.WrapSDKContext(ctx), msg)
            return sdk.WrapServiceResult(ctx, res, err)
        case *types.MsgRequestCall:
            res, err := msgServer.RequestCall(sdk.WrapSDKContext(ctx), msg)
            return sdk.WrapServiceResult(ctx, res, err)
        case *types.MsgConfirmUserAddress:
            res, err := msgServer.ConfirmUserAddress(sdk.WrapSDKContext(ctx), msg)
            return sdk.WrapServiceResult(ctx, res, err)
        case *types.MsgRequestUserAddress:
            res, err := msgServer.RequestUserAddress(sdk.WrapSDKContext(ctx), msg)
            return sdk.WrapServiceResult(ctx, res, err)
        case *types.MsgSendEthClaim:
            res, err := msgServer.SendEthClaim(sdk.WrapSDKContext(ctx), msg)
            return sdk.WrapServiceResult(ctx, res, err)
        case *types.MsgSetEthClaim:
            res, err := msgServer.SetEthClaim(sdk.WrapSDKContext(ctx), msg)
            return sdk.WrapServiceResult(ctx, res, err)
        case *types.MsgCancelBatch:
            res, err := msgServer.CancelBatch(sdk.WrapSDKContext(ctx), msg)
            return sdk.WrapServiceResult(ctx, res, err)
        case *types.MsgSetUserAddress:
            res, err := msgServer.SetUserAddress(sdk.WrapSDKContext(ctx), msg)
            return sdk.WrapServiceResult(ctx, res, err)
        case *types.MsgRequestEthClaim:
            res, err := msgServer.RequestEthClaim(sdk.WrapSDKContext(ctx), msg)
            return sdk.WrapServiceResult(ctx, res, err)
        case *types.MsgConfirmBatch:
            res, err := msgServer.ConfirmBatch(sdk.WrapSDKContext(ctx), msg)
            return sdk.WrapServiceResult(ctx, res, err)
        case *types.MsgUpdateBatch:
            res, err := msgServer.UpdateBatch(sdk.WrapSDKContext(ctx), msg)
            return sdk.WrapServiceResult(ctx, res, err)
        case *types.MsgSendUserAddress:
            res, err := msgServer.SendUserAddress(sdk.WrapSDKContext(ctx), msg)
            return sdk.WrapServiceResult(ctx, res, err)

        default:
            return nil, sdkerrors.Wrap(sdkerrors.ErrUnknownRequest, fmt.Sprintf("Unrecognized Gravity Msg type: %v", msg.Type()))
        }
    }
}

And it is the CancelCall msg.

Mitigations

  • Use the recent Msg Service mechanism
  • Test all functionalities
  • Deploy static-analysis tests in CI pipeline for all manually maintained code that must be repeated in multiple files/methods

External examples

  • The bug occured in the Gravity Bridge. It was impossible to send evidence of malicious behavior, which impacted Gravity Bridge's security model.

Missing error handler

The idiomatic way of handling errors in Go is to compare the returned error to nil. This way of checking for errors gives the programmer a lot of control. However, when error handling is ignored it can also lead to numerous problems. The impact of this is most obvious in method calls in the bankKeeper module, which even causes some accounts with insufficient balances to perform SendCoin operations normally without triggering a transaction failure.

Example

In the following code, k.bankKeeper.SendCoins(ctx, sender, receiver, amount) does not have any return values being used, including err. This results in SendCoin not being able to prevent the transaction from executing even if there is an error due to insufficient balance in SendCoin.

func (k msgServer) Transfer(goCtx context.Context, msg *types.MsgTransfer) (*types.MsgTransferResponse, error) {
	...
	k.bankKeeper.SendCoins(ctx, sender, receiver, amount)
	...
	return &types.MsgTransferResponse{}, nil
}

Mitigations

  • Implement the error handling process instead of missing it

External examples

(Not So) Smart Contracts

This repository contains examples of common Solana smart contract vulnerabilities, including code from real smart contracts. Use Not So Smart Contracts to learn about Solana vulnerabilities, as a reference when performing security reviews, and as a benchmark for security and analysis tools.

Features

Each Not So Smart Contract includes a standard set of information:

  • Description of the vulnerability type
  • Attack scenarios to exploit the vulnerability
  • Recommendations to eliminate or mitigate the vulnerability
  • Real-world contracts that exhibit the flaw
  • References to third-party resources with more information

Vulnerabilities

Not So Smart ContractDescription
Arbitrary CPIArbitrary program account passed in upon invocation
Improper PDA ValidationPDAs are vulnerable to being spoofed via bump seeds
Ownership CheckBroken access control due to missing ownership validation
Signer CheckBroken access control due to missing signer validation
Sysvar Account CheckSysvar accounts are vulnerable to being spoofed
Improper Instruction IntrospectionProgram accesses instruction using absolute index

Credits

These examples are developed and maintained by Trail of Bits.

If you have questions, problems, or just want to learn more, then join the #solana channel on the Empire Hacking Slack or contact us directly.

Arbitrary CPI

Solana allows programs to call one another through cross-program invocation (CPI). This can be done via invoke, which is responsible for routing the passed in instruction to the program. Whenever an external contract is invoked via CPI, the program must check and verify the program ID. If the program ID isn't verified, then the contract can call an attacker-controlled program instead of the intended one.

View ToB's lint implementation for the arbitrary CPI issue here.

Exploit Scenario

Consider the following withdraw function. Tokens are able to be withdrawn from the pool to a user account. The program invoked here is user-controlled and there's no check that the program passed in is the intended token_program. This allows a malicious user to pass in their own program with functionality to their discretion - such as draining the pool of the inputted amount tokens.

Example Contract

#![allow(unused)]
fn main() {
   pub fn withdraw(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
        let account_info_iter = &mut accounts.iter();
        let token_program = next_account_info(account_info_iter)?;
        let pool = next_account_info(account_info_iter)?;
        let pool_auth = next_account_info(account_info_iter)?;
        let destination = next_account_info(account_info_iter)?;
        invoke(
            &spl_token::instruction::transfer(
                &token_program.key,
                &pool.key,
                &destination.key,
                &pool_auth.key,
                &[],
                amount,
            )?,
            &[
                &pool.clone(),
                &destination.clone(),
                &pool_auth.clone(),
            ],
        )
    }
}

Inspired by Sealevel

Mitigation

#![allow(unused)]
fn main() {
        if INPUTTED_PROGRAM.key != &INTENDED_PROGRAM::id() {
            return Err(ProgramError::InvalidProgramId);
        }
}

Improper PDA bump seed validation

PDAs (Program Derived Addresses) are, by definition, program-controlled accounts and therefore can be used to sign without the need to provide a private key. PDAs are generated through a set of seeds and a program id, which are then collectively hashed to verify that the point doesn't lie on the ed25519 curve (the curve used by Solana to sign transactions).

Values on this elliptic curve have a corresponding private key, which wouldn't make it a PDA. In the case a point lying on the elliptic curve is found, our 32-byte address is modified through the addition of a bump to "bump" it off the curve. A bump, represented by a singular byte iterating through 255 to 0, is added onto our input until a point that doesn’t lie on the elliptic curve is generated, meaning that we’ve found an address without an associated private key.

The issue arises with seeds being able to have multiple bumps, thus allowing varying PDAs that are valid from the same seeds. An attacker can create a PDA with the correct program ID but with a different bump. Without any explicit check against the bump seed itself, the program leaves itself vulnerable to the attacker tricking the program into thinking they’re using the expected PDA when in fact they're interacting with an illegitimate account.

View ToB's lint implementation for the bump seed canonicalization issue here.

Exploit Scenario

In Solana, the create_program_address function creates a 32-byte address based off the set of seeds and program address. On its own, the point may lie on the ed25519 curve. Consider the following without any other validation being referenced within a sensitive function, such as one that handles transfers. That PDA could be spoofed by a passed in user-controlled PDA.

Example Contract

#![allow(unused)]
fn main() {
let program_address = Pubkey::create_program_address(&[key.to_le_bytes().as_ref(), &[reserve_bump]], program_id)?;

...
}

Mitigation

The find_program_address function finds the largest bump seeds for which there exists a corresponding PDA (i.e., a point not on the ed25519 curve), and returns both the address and the bump seed. The function panics in the case that no PDA address can be found.

#![allow(unused)]
fn main() {
        let (address, _system_bump) = Pubkey::find_program_address(&[key.to_le_bytes().as_ref()], program_id);

        if program_address != &account_data.key() {
            return Err(ProgramError::InvalidAddress);
        }
}

Missing Ownership Check

Accounts in Solana include metadata of an owner. These owners are identified by their own program ID. Without sufficient checks that the expected program ID matches that of the passed in account, an attacker can fabricate an account with spoofed data to pass any other preconditions.

This malicious account will inherently have a different program ID as owner, but considering there’s no check that the program ID is the same, as long as the other preconditions are passed, the attacker can trick the program into thinking their malicious account is the expected account.

Exploit Scenario

The following contract allows funds to be dispersed from an escrow account vault, provided the escrow account's state is Complete. Unfortunately, there is no check that the State account is owned by the program. Therefore, a malicious actor can pass in their own fabricated State account with spoofed data, allowing the attacker to send the vault's funds to themselves.

Example Contract

#![allow(unused)]
fn main() {
fn pay_escrow(_program_id: &Pubkey, accounts: &[AccountInfo], _instruction_data: &[u8]) -> ProgramResult {
    let account_info_iter = &mut accounts.iter();
    let state_info = next_account_info(account_info_iter)?;
    let escrow_vault_info = next_account_info(account_info_iter)?;
    let escrow_receiver_info = next_account_info(account_info_iter)?;

    let state = State::deserialize(&mut &**state_info.data.borrow())?;

    if state.escrow_state == EscrowState::Complete {
        **escrow_vault_info.try_borrow_mut_lamports()? -= state.amount;
        **escrow_receiver_info.try_borrow_mut_lamports()? += state.amount;
    }

    Ok(())
}
}

Inspired by SPL Lending Program

Mitigation

#![allow(unused)]
fn main() {
  	if EXPECTED_ACCOUNT.owner != program_id {
    	    return Err(ProgramError::IncorrectProgramId);
	}
}

For further reading on different forms of account verification in Solana and implementation refer to the Solana Cookbook.

Missing Signer Check

In Solana, each public key has an associated private key that can be used to generate signatures. A transaction lists each account public key whose private key was used to generate a signature for the transaction. These signatures are verified using the inputted public keys prior to transaction execution.

In case certain permissions are required to perform a sensitive function of the contract, a missing signer check becomes an issue. Without this check, an attacker would be able to call the respective access controlled functions permissionlessly.

Exploit Scenario

The following contract sets an escrow account's state to Complete. Unfortunately, the contract does not check whether the State account's authority has signed the transaction. Therefore, a malicious actor can set the state to Complete, without needing access to the authority’s private key.

Example Contract

#![allow(unused)]
fn main() {
fn complete_escrow(_program_id: &Pubkey, accounts: &[AccountInfo], _instruction_data: &[u8]) -> ProgramResult {
    let account_info_iter = &mut accounts.iter();
    let state_info = next_account_info(account_info_iter)?;
    let authority = next_account_info(account_info_iter)?;

    let mut state = State::deserialize(&mut &**state_info.data.borrow())?;

    if state.authority != *authority.key {
        return Err(ProgramError::IncorrectAuthority);
    }

    state.escrow_state = EscrowState::Complete;
    state.serialize(&mut &mut **state_info.data.borrow_mut())?;

    Ok(())

}
}

Inspired by SPL Lending Program

Mitigation

#![allow(unused)]
fn main() {
  	if !EXPECTED_ACCOUNT.is_signer {
    	return Err(ProgramError::MissingRequiredSignature);
	}
}

For further reading on different forms of account verification in Solana and implementation refer to the Solana Cookbook.

Missing Sysvar Account Check

The sysvar (system account) account is often used while validating access control for restricted functions by confirming that the inputted sysvar account by the user matches up with the expected sysvar account. Without this check in place, any user is capable of passing in their own spoofed sysvar account and in turn bypassing any further authentication associated with it, causing potentially disastrous effects.

Exploit Scenario

secp256k1 is an elliptic curve used by a number of blockchains for signatures. Validating signatures is crucial as by bypassing signature checks, an attacker can gain access to restricted functions that could lead to drainage of funds.

Here, load_current_index and load_instruction_at are functions that don't verify that the inputted sysvar account is authorized, therefore allowing serialized maliciously fabricated data to sucessfully spoof as an authorized secp256k1 signature.

Example Contract

#![allow(unused)]
fn main() {
pub fn verify_signatures(account_info: &AccountInfo) -> ProgramResult {
    let index = solana_program::sysvar::instructions::load_current_index(
        &account_info.try_borrow_mut_data()?,
    );

    let secp_instruction = sysvar::instructions::load_instruction_at(
        (index - 1) as usize,
        &account_info.try_borrow_mut_data()?,
    );
    if secp_instruction.program_id != secp256k1_program::id() {
        return Err(InvalidSecpInstruction.into());
    }
    ...
}
}

Refer to Mitigation to understand what's wrong with these functions and how sysvar account checks were added.

Mitigation

  • Solana libraries should be running on version 1.8.1 and up
  • Use load_instruction_at_checked and load_current_index_checked

Utilizing the latest Solana version and referencing checked functions, especially on sensitive parts of a contract is crucial even if potential attack vectors have been fixed post-audit. Leaving the system exposed to any point of failure compromises the entire system's integrity, especially while the contracts are being constantly updated.

Here is the code showing the sysvar account checks added between unchecked and checked functions:


Example: Wormhole Exploit (February 2022)

Funds lost: ~326,000,000 USD

Note: The following analysis is condensed down to be present this attack vector as clearly as possible, and certain details might’ve been left out for the sake of simplification

The Wormhole hack serves to be one of the most memorable exploits in terms of impact DeFi has ever seen.

This exploit also happens to incorporate a missing sysvar account check that allowed the attacker to:

  1. Spoof Guardian signatures as valid
  2. Use them to create a Validator Action Approval (VAA)
  3. Mint 120,000 ETH via calling complete_wrapped function

(These actions are all chronologically dependent on one another based on permissions and conditions - this analysis will only dive into “Step 1”)

The SignatureSet was able to be faked because the verify_signatures function failed to appropriately verify the sysvar account passed in:

#![allow(unused)]
fn main() {
let secp_ix = solana_program::sysvar::instructions::load_instruction_at(
    secp_ix_index as usize,
    &accs.instruction_acc.try_borrow_mut_data()?,
)
}

load_instruction_at doesn't verify that the inputted data came from the authorized sysvar account.

The fix for this was to upgrade the Solana version and get rid of these unsafe deprecated functions (see Mitigation). Wormhole had caught this issue but didn't update their deployed contracts in time before the exploiter had already managed to drain funds.

Resources:

samczsun's Wormhole exploit breakdown thread

Improper Instruction Introspection

Solana allows programs to inspect other instructions in the transaction using the Instructions sysvar. The programs requiring instruction introspection divide an operation into two or more instructions. The program have to ensure that all the instructions related to an operation are correlated. The program could access the instructions using absolute indexes or relative indexes. Using relative indexes ensures that the instructions are implicitly correlated. The programs using absolute indexes might become vulnerable to exploits if additional validations to ensure the correlation between instructions are not performed.

Exploit Scenario

A program mints tokens based on the amount of tokens transferred to it. A program checks that Token::transfer instruction is called in the first instruction of the transaction. The program uses absolute index 0 to access the instruction data, program id and validates them. If the first instruction is a Token::transfer then program mints some tokens.

#![allow(unused)]
fn main() {
pub fn mint(
    ctx: Context<Mint>,
    // ...
) -> Result<(), ProgramError> {
    // [...]
    let transfer_ix = solana_program::sysvar::instructions::load_instruction_at_checked(
        0usize,
        ctx.instructions_account.to_account_info(),
    )?;

    if transfer_ix.program_id != spl_token::id() {
        return Err(ProgramError::InvalidInstructionData);
    }
    // check transfer_ix transfers
    // mint to the user account
    // [...]
    Ok(())
}
}

The program uses absolute index to access the transfer instruction. An attacker can create transaction containing multiple calls to mint and single transfer instruction.

  1. transfer()
  2. mint(, ...)
  3. mint(, ...)
  4. mint(, ...)
  5. mint(, ...)
  6. mint(, ...)

All the mint instructions verify the same transfer instruction. The attacker gets 4 times more than the intended tokens.

Mitigation

Use a relative index, for example -1, and ensure the instruction at that offset is the transfer instruction.

#![allow(unused)]
fn main() {
pub fn mint(
    ctx: Context<Mint>,
    // ...
) -> Result<(), ProgramError> {
    // [...]
    let transfer_ix = solana_program::sysvar::instructions::get_instruction_relative(
        -1i64,
        ctx.instructions_account.to_account_info(),
    )?;
    // [...]
}
}

(Not So) Smart Pallets

This repository contains examples of common Substrate pallet vulnerabilities. Use Not So Smart Pallets to learn about Substrate vulnerabilities, as a reference when performing security reviews, and as a benchmark for security and analysis tools.

Features

Each Not So Smart Pallet includes a standard set of information:

  • Description of the vulnerability type
  • Attack scenarios to exploit the vulnerability
  • Recommendations to eliminate or mitigate the vulnerability
  • A mock pallet that exhibits the flaw
  • References to third-party resources with more information

Vulnerabilities

Not So Smart PalletDescription
Arithmetic overflowInteger overflow due to incorrect use of arithmetic operators
Don't panic!System panics create a potential DoS attack vector
Weights and feesIncorrect weight calculations can create a potential DoS attack vector
Verify firstVerify first, write last
Unsigned transaction validationInsufficient validation of unsigned transactions
Bad randomnessUnsafe sources of randomness in Substrate
Bad originIncorrect use of call origin can lead to bypassing access controls

Credits

These examples are developed and maintained by Trail of Bits.

If you have questions, problems, or just want to learn more, then join the #ethereum channel on the Empire Hacking Slack or contact us directly.

Arithmetic overflow

Arithmetic overflow in Substrate occurs when arithmetic operations are performed using primitive operations instead of specialized functions that check for overflow. When a Substrate node is compiled in debug mode, integer overflows will cause the program to panic. However, when the node is compiled in release mode (e.g. cargo build --release), Substrate will perform two's complement wrapping. A production-ready node will be compiled in release mode, which makes it vulnerable to arithmetic overflow.

Example

In the pallet-overflow pallet, notice that the transfer function sets update_sender and update_to using primitive arithmetic operations.

#![allow(unused)]
fn main() {
   /// Allow minting account to transfer a given balance to another account.
   ///
   /// Parameters:
   /// - `to`: The account to receive the transfer.
   /// - `amount`: The amount of balance to transfer.
   ///
   /// Emits `Transferred` event when successful.
   #[pallet::weight(10_000)]
   pub fn transfer(
       origin: OriginFor<T>,
       to: T::AccountId,
       amount: u64,
   ) -> DispatchResultWithPostInfo {
       let sender = ensure_signed(origin)?;
       let sender_balance = Self::get_balance(&sender);
       let receiver_balance = Self::get_balance(&to);

       // Calculate new balances.
       let update_sender = sender_balance - amount;
       let update_to = receiver_balance + amount;
       [...]
   }
}

The sender of the extrinsic can exploit this vulnerability by causing update_sender to underflow, which artificially inflates their balance.

Note: Aside from the stronger mitigations mentioned below, a check to make sure that sender has at least amount balance would have also prevented an underflow.

Mitigations

References

  • https://doc.rust-lang.org/book/ch03-02-data-types.html#integer-overflow
  • https://docs.substrate.io/reference/how-to-guides/basics/use-helper-functions/

Don't Panic!

Panics occur when the node enters a state that it cannot handle and stops the program / process instead of trying to proceed. Panics can occur for a large variety of reasons such as out-of-bounds array access, incorrect data validation, type conversions, and much more. A well-designed Substrate node must NEVER panic! If a node panics, it opens up the possibility for a denial-of-service (DoS) attack.

Example

In the pallet-dont-panic pallet, the find_important_value dispatchable checks to see if useful_amounts[0] is greater than 1_000. If so, it sets the ImportantVal StorageValue to the value held in useful_amounts[0].

#![allow(unused)]
fn main() {
    /// Do some work
    ///
    /// Parameters:
    /// - `useful_amounts`: A vector of u64 values in which there is a important value.
    ///
    /// Emits `FoundVal` event when successful.
    #[pallet::weight(10_000)]
    pub fn find_important_value(
        origin: OriginFor<T>,
        useful_amounts: Vec<u64>,
    ) -> DispatchResultWithPostInfo {
        let sender = ensure_signed(origin)?;

        ensure!(useful_amounts[0] > 1_000, <Error<T>>::NoImportantValueFound);

        // Found the important value
        ImportantValue::<T>::put(&useful_amounts[0]);
        [...]
    }
}

However, notice that there is no check before the array indexing to see whether the length of useful_amounts is greater than zero. Thus, if useful_amounts is empty, the indexing will cause an array out-of-bounds error which will make the node panic. Since the find_important_value function is callable by anyone, an attacker can set useful_amounts to an empty array and spam the network with malicious transactions to launch a DoS attack.

Mitigations

  • Write non-throwing Rust code (e.g. prefer returning Result types, use ensure!, etc.).
  • Proper data validation of all input parameters is crucial to ensure that an unexpected panic does not occur.
  • A thorough suite of unit tests should be implemented.
  • Fuzz testing (e.g. using test-fuzz) can aid in exploring more of the input space.

References

  • https://docs.substrate.io/main-docs/build/events-errors/#errors

Weights and Fees

Weights and transaction fees are the two main ways to regulate the consumption of blockchain resources. The overuse of blockchain resources can allow a malicious actor to spam the network to cause a denial-of-service (DoS). Weights are used to manage the time it takes to validate the block. The larger the weight, the more "resources" / time the computation takes. Transaction fees provide an economic incentive to limit the number of resources used to perform operations; the fee for a given transaction is a function of the weight required by the transaction.

Weights can be fixed or a custom "weight annotation / function" can be implemented. A weight function can calculate the weight, for example, based on the number of database read / writes and the size of the input paramaters (e.g. a long Vec<>). To optimize the weight such that users do not pay too little or too much for a transaction, benchmarking can be used to empirically determine the correct weight in worst case scenarios.

Specifying the correct weight function and benchmarking it is crucial to protect the Substrate node from denial-of-service (DoS) attacks. Since fees are a function of weight, a bad weight function implies incorrect fees. For example, if some function performs heavy computation (which takes a lot of time) but specifies a very small weight, it is cheap to call that function. In this way an attacker can perform a low-cost attack while still stealing a large amount of block execution time. This will prevent regular transactions from being fit into those blocks.

Example

In the pallet-bad-weights pallet, a custom weight function, MyWeightFunction, is used to calculate the weight for a call to do_work. The weight required for a call to do_work is 10_000_000 times the length of the useful_amounts vector.

#![allow(unused)]
fn main() {
impl WeighData<(&Vec<u64>,)> for MyWeightFunction {
    fn weigh_data(&self, (amounts,): (&Vec<u64>,)) -> Weight {
        self.0.saturating_mul(amounts.len() as u64).into()
    }
}
}

However, if the length of the useful_amounts vector is zero, the weight to call do_work would be zero. A weight of zero implies that calling this function is financially cheap. This opens the opportunity for an attacker to call do_work a large number of times to saturate the network with malicious transactions without having to pay a large fee and could cause a DoS attack.

One potential fix for this is to set a fixed weight if the length of useful_amounts is zero.

#![allow(unused)]
fn main() {
impl WeighData<(&Vec<u64>,)> for MyWeightFunction {
    fn weigh_data(&self, (amounts,): (&Vec<u64>,)) -> Weight {
        // The weight function is `y = mx + b` where `m` and `b` are both `self.0` (the static fee) and `x` is the length of the `amounts` array.
        // If `amounts.len() == 0` then the weight is simply the static fee (i.e. `y = b`)
        self.0 + self.0.saturating_mul(amounts.len() as u64).into()
    }
}
}

In the example above, if the length of amounts (i.e. useful_amounts) is zero, then the function will return self.0 (i.e. 10_000_000).

On the other hand, if an attacker sends a useful_amounts vector that is incredibly large, the returned Weight can become large enough such that the dispatchable takes up a large amount block execution time and prevents other transactions from being fit into the block. A fix for this would be to bound the maximum allowable length for useful_amounts.

Note: Custom fee functions can also be created. These functions should also be carefully evaluated and tested to ensure that the risk of DoS attacks is mitigated.

Mitigations

  • Use benchmarking to empirically test the computational resources utilized by various dispatchable functions. Additionally, use benchmarking to define a lower and upper weight bound for each dispatchable.
  • Create bounds for input arguments to prevent a transaction from taking up too many computational resources. For example, if a Vec<> is being taken as an input argument to a function, prevent the length of the Vec<> from being too large.
  • Be wary of fixed weight dispatchables (e.g. #[pallet::weight(1_000_000)]). A weight that is completely agnostic to database read / writes or input parameters may open up avenues for DoS attacks.

References

  • https://docs.substrate.io/main-docs/build/tx-weights-fees/
  • https://docs.substrate.io/reference/how-to-guides/weights/add-benchmarks/

Verify First, Write Last

NOTE: As of Polkadot v0.9.25, the Verify First, Write Last practice is no longer required. However, since older versions are still vulnerable and because it is still best practice, it is worth discussing the "Verify First, Write Last" idiom.

Substrate does not cache state prior to extrinsic dispatch. Instead, state changes are made as they are invoked. This is in contrast to a transaction in Ethereum where, if the transaction reverts, no state changes will persist. In the case of Substrate, if a state change is made and then the dispatch throws a DispatchError, the original state change will persist. This unique behavior has led to the "Verify First, Write Last" practice.

#![allow(unused)]
fn main() {
{
  // all checks and throwing code go here

  // ** no throwing code below this line **

  // all event emissions & storage writes go here
}
}

Example

In the pallet-verify-first pallet, the init dispatchable is used to set up the TotalSupply of the token and transfer them to the sender. init should be only called once. Thus, the Init boolean is set to true when it is called initially. If init is called more than once, the transaction will throw an error because Init is already true.

#![allow(unused)]
fn main() {
/// Initialize the token
/// Transfers the total_supply amount to the caller
/// If init() has already been called, throw AlreadyInitialized error
#[pallet::weight(10_000)]
pub fn init(
    origin: OriginFor<T>,
    supply: u64
) -> DispatchResultWithPostInfo {
    let sender = ensure_signed(origin)?;

    if supply > 0 {
        <TotalSupply<T>>::put(&supply);
    }
    // Set sender's balance to total_supply()
    <BalanceToAccount<T>>::insert(&sender, supply);

    // Revert above changes if init() has already been called
    ensure!(!Self::is_init(), <Error<T>>::AlreadyInitialized);

    // Set Init StorageValue to `true`
    Init::<T>::put(true);

    // Emit event
    Self::deposit_event(Event::Initialized(sender));

    Ok(().into())
}
}

However, notice that the setting of TotalSupply and the transfer of funds happens before the check on Init. This violates the "Verify First, Write Last" practice. In an older version of Substrate, this would allow a malicious sender to call init multiple times and change the value of TotalSupply and their balance of the token.

Mitigations

  • Follow the "Verify First, Write Last" practice by doing all the necessary data validation before performing state changes and emitting events.

References

  • https://docs.substrate.io/main-docs/build/runtime-storage/#best-practices

Unsigned Transaction Validation

There are three types of transactions allowed in a Substrate runtime: signed, unsigned, and inherent. An unsigned transaction does not require a signature and does not include information about who sent the transaction. This is naturally problematic because there is no by-default deterrent to spam or replay attacks. Because of this, Substrate allows users to create custom functions to validate unsigned transaction. However, incorrect or improper validation of an unsigned transaction can allow anyone to perform potentially malicious actions. Usually, unsigned transactions are allowed only for select parties (e.g., off-chain workers (OCW)). But, if improper data validation of an unsigned transaction allows a malicious actor to spoof data as if it came from an OCW, this can lead to unexpected behavior. Additionally, improper validation opens up the possibility to replay attacks where the same transaction can be sent to the transaction pool again and again to perform some malicious action repeatedly.

The validation of an unsigned transaction must be provided by the pallet that chooses to accept them. To allow unsigned transactions, a pallet must implement the frame_support::unsigned::ValidateUnsigned trait. The validate_unsigned function, which must be implemented as part of the ValidateUnsigned trait, will provide the logic necessary to ensure that the transaction is valid. An off chain worker (OCW) can be implemented directly in a pallet using the offchain_worker hook. The OCW can send an unsigned transaction by calling SubmitTransaction::submit_unsigned_transaction. Upon submission, the validate_unsigned function will ensure that the transaction is valid and then pass the transaction on towards towards the final dispatchable function.

Example

The pallet-bad-unsigned pallet is an example that showcases improper unsigned transaction validation. The pallet tracks the average, rolling price of some "asset"; this price data is being retrieved by an OCW. The fetch_price function, which is called by the OCW, naively returns 100 as the current price (note that an HTTP request can be made here for true price data). The validate_unsigned function (see below) simply validates that the Call is being made to submit_price_unsigned and nothing else.

#![allow(unused)]
fn main() {
/// By default unsigned transactions are disallowed, but implementing the validator
/// here we make sure that some particular calls (the ones produced by offchain worker)
/// are being whitelisted and marked as valid.
fn validate_unsigned(_source: TransactionSource, call: &Self::Call) -> TransactionValidity {
    // If `submit_price_unsigned` is being called, the transaction is valid.
    // Otherwise, it is an InvalidTransaction.
    if let Call::submit_price_unsigned { block_number, price: new_price } = call {
        let avg_price = Self::average_price()
        .map(|price| if &price > new_price { price - new_price } else { new_price - price })
        .unwrap_or(0);

        let valid_tx = | provide | {
            ValidTransaction::with_tag_prefix("ExampleOffchainWorker")
            .priority(T::UnsignedPriority::get().saturating_add(avg_price as _))
            .and_provides([&provide])
            .longevity(5)
            .propagate(true)
            .build()
        };
        valid_tx(b"submit_price_unsigned".to_vec())
    } else {
        InvalidTransaction::Call.into()
    }
}
}

However, notice that there is nothing preventing an attacker from sending malicious price data. Both block_number and price can be set to arbitrary values. For block_number, it would be valuable to ensure that it is not a block number in the future; only price data for the current block can be submitted. Additionally, medianization can be used to ensure that the reported price is not severely affected by outliers. Finally, unsigned submissions can be throttled by enforcing a delay after each submission.

Note that the simplest solution would be to sign the offchain submissions so that the runtime can validate that a known OCW is sending the price submission transactions.

Mitigations

  • Consider whether unsigned transactions is a requirement for the runtime that is being built. OCWs can also submit signed transactions or transactions with signed payloads.
  • Ensure that each parameter provided to validate_unsigned is validated to prevent the runtime from entering a state that is vulnerable or undefined.

References

  • https://docs.substrate.io/main-docs/fundamentals/transaction-types/#unsigned-transactions
  • https://docs.substrate.io/main-docs/fundamentals/offchain-operations/
  • https://github.com/paritytech/substrate/blob/polkadot-v0.9.26/frame/examples/offchain-worker/src/lib.rs
  • https://docs.substrate.io/tutorials/build-application-logic/add-offchain-workers/
  • https://docs.substrate.io/reference/how-to-guides/offchain-workers/offchain-http-requests/

Bad Randomness

To use randomness in a Substrate pallet, all you need to do is require a source of randomness in the Config trait of a pallet. This source of Randomness must implement the Randomness trait. The trait provides two methods for obtaining randomness.

  1. random_seed: This function takes no arguments and returns back a random value. Calling this value multiple times in a block will result in the same value.
  2. random: Takes in a byte-array (a.k.a "context-identifier") and returns a value that is as independent as possible from other contexts.

Substrate provides the Randomness Collective Flip Pallet and a Verifiable Random Function implementation in the BABE pallet. Developers can also choose to build their own source of randomness.

A bad source of randomness can lead to a variety of exploits such as the theft of funds or undefined system behavior.

Example

The pallet-bad-lottery pallet is a simplified "lottery" system that requires one to guess the next random number. If they guess correctly, they are the winner of the lottery.

#![allow(unused)]
fn main() {
#[pallet::call]
impl<T:Config> Pallet<T> {
/// Guess the random value
/// If you guess correctly, you become the winner
#[pallet::weight(10_000)]
pub fn guess(
    origin: OriginFor<T>,
    guess: T::Hash
) -> DispatchResultWithPostInfo {
    let sender = ensure_signed(origin)?;
    // Random value.
    let nonce = Self::get_and_increment_nonce();
    let (random_value, _) = T::MyRandomness::random(&nonce);
    // Check if guess is correct
    ensure!(guess == random_value, <Error<T>>::IncorrectGuess);
    <Winner<T>>::put(&sender);

    Self::deposit_event(Event::NewWinner(sender));

    Ok(().into())
}
}

impl<T:Config> Pallet<T> {
/// Increment the nonce each time guess() is called
pub fn get_and_increment_nonce() -> Vec<u8> {
    let nonce = Nonce::<T>::get();
    Nonce::<T>::put(nonce.wrapping_add(1));
    nonce.encode()
}
}
}

Note that the quality of randomness provided to the pallet-bad-lottery pallet is related to the randomness source. If the randomness source is the "Randomness Collective Flip Pallet", this lottery system is insecure. This is because the collective flip pallet implements "low-influence randomness". This makes it vulnerable to a collusion attack where a small minority of participants can give the same random number contribution making it highly likely to have the seed be this random number (click here to learn more). Additionally, as mentioned in the Substrate documentation, "low-influence randomness can be useful when defending against relatively weak adversaries. Using this pallet as a randomness source is advisable primarily in low-security situations like testing."

Mitigations

  • Use the randomness implementation provided by the BABE pallet. This pallet provides "production-grade randomness, and is used in Polkadot. Selecting this randomness source dictates that your blockchain use Babe consensus."
  • Defer from creating a custom source of randomness unless specifically necessary for the runtime being developed.
  • Do not use random_seed as the method of choice for randomness unless specifically necessary for the runtime being developed.

References

  • https://docs.substrate.io/main-docs/build/randomness/
  • https://docs.substrate.io/reference/how-to-guides/pallet-design/incorporate-randomness/
  • https://ethresear.ch/t/rng-exploitability-analysis-assuming-pure-randao-based-main-chain/1825/7
  • https://ethresear.ch/t/collective-coin-flipping-csprng/3252/21
  • https://github.com/paritytech/ink/issues/57#issuecomment-486998848

Origins

The origin of a call tells a dispatchable function where the call has come from. Origins are a way to implement access controls in the system.

There are three types of origins that can used in the runtime:

#![allow(unused)]
fn main() {
pub enum RawOrigin<AccountId> {
	Root,
	Signed(AccountId),
	None,
}
}

Outside of the out-of-box origins, custom origins can also be created that are catered to a specific runtime. The primary use case for custom origins is to configure privileged access to dispatch calls in the runtime, outside of RawOrigin::Root.

Using privileged origins, like RawOrigin::Root or custom origins, can lead to access control violations if not used correctly. It is a common error to use ensure_signed in place of ensure_root which would allow any user to bypass the access control placed by using ensure_root.

Example

In the pallet-bad-origin pallet, there is a set_important_val function that should be only callable by the ForceOrigin custom origin type. This custom origin allows the pallet to specify that only a specific account can call set_important_val.

#![allow(unused)]
fn main() {
#[pallet::call]
impl<T:Config> Pallet<T> {
    /// Set the important val
    /// Should be only callable by ForceOrigin
    #[pallet::weight(10_000)]
    pub fn set_important_val(
        origin: OriginFor<T>,
        new_val: u64
    ) -> DispatchResultWithPostInfo {
        let sender = ensure_signed(origin)?;
        // Change to new value
        <ImportantVal<T>>::put(new_val);

        // Emit event
        Self::deposit_event(Event::ImportantValSet(sender, new_val));

        Ok(().into())
    }
}
}

However, the set_important_val is using ensure_signed; this allows any account to set ImportantVal. To allow only the ForceOrigin to call set_important_val the following change can be made:

#![allow(unused)]
fn main() {
T::ForceOrigin::ensure_origin(origin.clone())?;
let sender = ensure_signed(origin)?;
}

Mitigations

  • Ensure that the correct access controls are placed on privileged functions.
  • Develop user documentation on all risks associated with the system, including those associated with privileged users.
  • A thorough suite of unit tests that validates access controls is crucial.

References

  • https://docs.substrate.io/main-docs/build/origins/
  • https://docs.substrate.io/tutorials/build-application-logic/specify-the-origin-for-a-call/
  • https://paritytech.github.io/substrate/master/pallet_sudo/index.html#
  • https://paritytech.github.io/substrate/master/pallet_democracy/index.html

Program Analysis

We will use three distinctive testing and program analysis techniques:

  • Static analysis with Slither. All the paths of the program are approximated and analyzed simultaneously through different program presentations (e.g., control-flow-graph).
  • Fuzzing with Echidna. The code is executed with a pseudo-random generation of transactions. The fuzzer attempts to find a sequence of transactions that violates a given property.
  • Symbolic execution with Manticore. This formal verification technique translates each execution path into a mathematical formula on which constraints can be checked.

Each technique has its advantages and pitfalls, making them useful in specific cases:

TechniqueToolUsageSpeedBugs missedFalse Alarms
Static AnalysisSlitherCLI & scriptssecondsmoderatelow
FuzzingEchidnaSolidity propertiesminuteslownone
Symbolic ExecutionManticoreSolidity properties & scriptshoursnone*none

* if all paths are explored without timeout

Slither analyzes contracts within seconds. However, static analysis might lead to false alarms and is less suitable for complex checks (e.g., arithmetic checks). Run Slither via the CLI for push-button access to built-in detectors or via the API for user-defined checks.

Echidna needs to run for several minutes and will only produce true positives. Echidna checks user-provided security properties written in Solidity. It might miss bugs since it is based on random exploration.

Manticore performs the "heaviest weight" analysis. Like Echidna, Manticore verifies user-provided properties. It will need more time to run, but it can prove the validity of a property and will not report false alarms.

Suggested Workflow

Start with Slither's built-in detectors to ensure that no simple bugs are present now or will be introduced later. Use Slither to check properties related to inheritance, variable dependencies, and structural issues. As the codebase grows, use Echidna to test more complex properties of the state machine. Revisit Slither to develop custom checks for protections unavailable from Solidity, like protecting against a function being overridden. Finally, use Manticore to perform targeted verification of critical security properties, e.g., arithmetic operations.

  • Use Slither's CLI to catch common issues
  • Use Echidna to test high-level security properties of your contract
  • Use Slither to write custom static checks
  • Use Manticore for in-depth assurance of critical security properties

A note on unit tests: Unit tests are necessary for building high-quality software. However, these techniques are not best suited for finding security flaws. They typically test positive behaviors of code (i.e., the code works as expected in normal contexts), while security flaws tend to reside in edge cases that developers did not consider. In our study of dozens of smart contract security reviews, unit test coverage had no effect on the number or severity of security flaws we found in our client's code.

Determining Security Properties

To effectively test and verify your code, you must identify the areas that need attention. As your resources spent on security are limited, scoping the weak or high-value parts of your codebase is important to optimize your effort. Threat modeling can help. Consider reviewing:

Components

Knowing what you want to check also helps you select the right tool.

The broad areas frequently relevant for smart contracts include:

  • State machine. Most contracts can be represented as a state machine. Consider checking that (1) no invalid state can be reached, (2) if a state is valid, then it can be reached, and (3) no state traps the contract.

    • Echidna and Manticore are the tools to favor for testing state-machine specifications.
  • Access controls. If your system has privileged users (e.g., an owner, controllers, ...), you must ensure that (1) each user can only perform the authorized actions and (2) no user can block actions from a more privileged user.

    • Slither, Echidna, and Manticore can check for correct access controls. For example, Slither can check that only whitelisted functions lack the onlyOwner modifier. Echidna and Manticore are useful for more complex access control, such as permission being given only if the contract reaches a specific state.
  • Arithmetic operations. Checking the soundness of arithmetic operations is critical. Using SafeMath everywhere is a good step to prevent overflow/underflow, but you must still consider other arithmetic flaws, including rounding issues and flaws that trap the contract.

    • Manticore is the best choice here. Echidna can be used if the arithmetic is out-of-scope of the SMT solver.
  • Inheritance correctness. Solidity contracts rely heavily on multiple inheritance. Mistakes like a shadowing function missing a super call and misinterpreted c3 linearization order can easily be introduced.

    • Slither is the tool for detecting these issues.
  • External interactions. Contracts interact with each other, and some external contracts should not be trusted. For example, if your contract relies on external oracles, will it remain secure if half the available oracles are compromised?

    • Manticore and Echidna are the best choices for testing external interactions with your contracts. Manticore has a built-in mechanism to stub external contracts.
  • Standard conformance. Ethereum standards (e.g., ERC20) have a history of design flaws. Be aware of the limitations of the standard you are building on.

    • Slither, Echidna, and Manticore will help you detect deviations from a given standard.

Tool Selection Cheatsheet

ComponentToolsExamples
State machineEchidna, Manticore
Access controlSlither, Echidna, ManticoreSlither exercise 2, Echidna exercise 2
Arithmetic operationsManticore, EchidnaEchidna exercise 1, Manticore exercises 1 - 3
Inheritance correctnessSlitherSlither exercise 1
External interactionsManticore, Echidna
Standard conformanceSlither, Echidna, Manticoreslither-erc

Other areas will need to be checked depending on your goals, but these coarse-grained areas of focus are a good start for any smart contract system.

Our public audits contain examples of verified or tested properties. Consider reading the Automated Testing and Verification sections of the following reports to review real-world security properties:

Echidna Tutorial

The objective of this tutorial is to demonstrate how to use Echidna to automatically test smart contracts.

To learn through live coding sessions, watch our Fuzzing workshop.

Table of Contents:

Join the team on Slack at: https://slack.empirehacking.nyc/ #ethereum.

If you are looking for help to build fuzzing capabilities for your team, check out our invariant development as a service.

Introduction

Introductory materials for fuzzing and Echidna:

Installation

Echidna can be installed either through Docker or by using the pre-compiled binary.

MacOS

To install Echidna on MacOS, simply run the following command:

brew install echidna.

Echidna via Docker

To install Echidna using Docker, execute the following commands:

docker pull trailofbits/eth-security-toolbox
docker run -it -v "$PWD":/home/training trailofbits/eth-security-toolbox

The last command runs the eth-security-toolbox in a Docker container, which will have access to your current directory. This allows you to modify the files on your host machine and run the tools on those files within the container.

Inside Docker, execute the following commands:

solc-select use 0.8.0
cd /home/training

Binary

You can find the latest released binary here:

https://github.com/crytic/echidna/releases/latest

It's essential to use the correct solc version to ensure that these exercises work as expected. We have tested them using version 0.8.0.

Introduction to Property-Based Fuzzing

Echidna is a property-based fuzzer, which we have described in our previous blog posts (1, 2, 3).

Fuzzing

Fuzzing is a well-known technique in the security community. It involves generating more or less random inputs to find bugs in a program. Fuzzers for traditional software (such as AFL or LibFuzzer) are known to be efficient tools for bug discovery.

Beyond purely random input generation, there are many techniques and strategies used for generating good inputs, including:

  • Obtaining feedback from each execution and guiding input generation with it. For example, if a newly generated input leads to the discovery of a new path, it makes sense to generate new inputs closer to it.
  • Generating input with respect to a structural constraint. For instance, if your input contains a header with a checksum, it makes sense to let the fuzzer generate input that validates the checksum.
  • Using known inputs to generate new inputs. If you have access to a large dataset of valid input, your fuzzer can generate new inputs from them, rather than starting from scratch for each generation. These are usually called seeds.

Property-Based Fuzzing

Echidna belongs to a specific family of fuzzers: property-based fuzzing, which is heavily inspired by QuickCheck. In contrast to a classic fuzzer that tries to find crashes, Echidna aims to break user-defined invariants.

In smart contracts, invariants are Solidity functions that can represent any incorrect or invalid state that the contract can reach, including:

  • Incorrect access control: The attacker becomes the owner of the contract.
  • Incorrect state machine: Tokens can be transferred while the contract is paused.
  • Incorrect arithmetic: The user can underflow their balance and get unlimited free tokens.

Testing a Property with Echidna

Table of Contents:

Introduction

This tutorial demonstrates how to test a smart contract with Echidna. The target is the following smart contract (token.sol):

contract Token {
    mapping(address => uint256) public balances;

    function airdrop() public {
        balances[msg.sender] = 1000;
    }

    function consume() public {
        require(balances[msg.sender] > 0);
        balances[msg.sender] -= 1;
    }

    function backdoor() public {
        balances[msg.sender] += 1;
    }
}

We will assume that this token has the following properties:

  • Anyone can hold a maximum of 1000 tokens.

  • The token cannot be transferred (it is not an ERC20 token).

Write a Property

Echidna properties are Solidity functions. A property must:

  • Have no arguments.

  • Return true if successful.

  • Have its name starting with echidna.

Echidna will:

  • Automatically generate arbitrary transactions to test the property.

  • Report any transactions that lead a property to return false or throw an error.

  • Discard side-effects when calling a property (i.e., if the property changes a state variable, it is discarded after the test).

The following property checks that the caller can have no more than 1000 tokens:

function echidna_balance_under_1000() public view returns (bool) {
    return balances[msg.sender] <= 1000;
}

Use inheritance to separate your contract from your properties:

contract TestToken is Token {
    function echidna_balance_under_1000() public view returns (bool) {
        return balances[msg.sender] <= 1000;
    }
}

testtoken.sol implements the property and inherits from the token.

Initiate a Contract

Echidna requires a constructor without input arguments. If your contract needs specific initialization, you should do it in the constructor.

There are some specific addresses in Echidna:

  • 0x30000 calls the constructor.

  • 0x10000, 0x20000, and 0x30000 randomly call other functions.

We don't need any particular initialization in our current example. As a result, our constructor is empty.

Run Echidna

Launch Echidna with:

echidna contract.sol

If contract.sol contains multiple contracts, you can specify the target:

echidna contract.sol --contract MyContract

Summary: Testing a Property

The following summarizes the Echidna run on our example:

contract TestToken is Token {
    constructor() public {}

    function echidna_balance_under_1000() public view returns (bool) {
        return balances[msg.sender] <= 1000;
    }
}
echidna testtoken.sol --contract TestToken
...

echidna_balance_under_1000: failed!💥
  Call sequence, shrinking (1205/5000):
    airdrop()
    backdoor()

...

Echidna found that the property is violated if the backdoor function is called.

Basic

How to Select the Most Suitable Testing Mode

Echidna offers several ways to write properties, which often leaves developers and auditors wondering about the most appropriate testing mode to use. In this section, we will review how each mode works, as well as their advantages and disadvantages.

Table of Contents:

Boolean Properties

By default, the "property" testing mode is used, which reports failures using special functions called properties:

  • Testing functions should be named with a specific prefix (e.g. echidna_).
  • Testing functions take no parameters and always return a boolean value.
  • Any side effect will be reverted at the end of the execution of the property.
  • Properties pass if they return true and fail if they return false or revert. Alternatively, properties that start with "echidnarevert" will fail if they return any value (true or false) and pass if they revert. This pseudo-code summarizes how properties work:
function echidna_property() public returns (bool) { // No arguments are required
  // The following statements can trigger a failure if they revert
  publicFunction(...);
  internalFunction(...);
  contract.function(...);

  // The following statement can trigger a failure depending on the returned value
  return ...;
} // side effects are *not* preserved

function echidna_revert_property() public returns (bool) { // No arguments are required
  // The following statements can *never* trigger a failure
  publicFunction(...);
  internalFunction(...);
  contract.function(...);

  // The following statement will *always* trigger a failure regardless of the value returned
  return ...;
} // side effects are *not* preserved

Advantages:

  • Properties can be easier to write and understand compared to other approaches for testing.
  • No need to worry about side effects since these are reverted at the end of the property execution.

Disadvantages:

  • Since the properties take no parameters, any additional input should be added using a state variable.
  • Any revert will be interpreted as a failure, which is not always expected.
  • No coverage is collected during its execution so these properties should be used with simple code. For anything complex (e.g., with a non-trivial amount of branches), other types of tests should be used.

Recommendations

This mode can be used when a property can be easily computed from the use of state variables (either internal or public), and there is no need to use extra parameters.

Assertions

Using the "assertion" testing mode, Echidna will report an assert violation if:

  • The execution reverts during a call to assert. Technically speaking, Echidna will detect an assertion failure if it executes an assert call that fails in the first call frame of the target contract (so this excludes most internal transactions).
  • An AssertionFailed event (with any number of parameters) is emitted by any contract. This pseudo-code summarizes how assertions work:
function checkInvariant(...) public { // Any number of arguments is supported
  // The following statements can trigger a failure using `assert`
  assert(...);
  publicFunction(...);
  internalFunction(...);

  // The following statement will always trigger a failure even if the execution ends with a revert
  emits AssertionFailed(...);

  // The following statement will *only* trigger a failure using `assert` if using solc 0.8.x or newer
  // To make sure it works in older versions, use the AssertionFailed(...) event
  anotherContract.function(...);

} // side effects are preserved

Functions checking assertions do not require any particular name and are executed like any other function; therefore, their side effects are retained if they do not revert.

Advantages

  • Easy to implement, especially if several parameters are required to compute the invariant.
  • Coverage is collected during the execution of these tests, so it can help to discover new failures.
  • If the code base already contains assertions for checking invariants, they can be reused.

Disadvantages

  • If the code to test is already using assertions for data validation, it will not work as expected. For example:
function deposit(uint256 tokens) public {
  assert(tokens > 0); // should be strictly positive
  ...
}

Developers should avoid doing that and use require instead, but if that is not possible because you are calling some contract that is outside your control, you can use the AssertionFailure event.

Recommendation

You should use assertions if your invariant is more naturally expressed using arguments or can only be checked in the middle of a transaction. Another good use case of assertions is complex code that requires checking something as well as changing the state. In the following example, we test staking some ERC20, given that there are at least MINSTAKE tokens in the sender balance.

function testStake(uint256 toStake) public {
    uint256 balance = balanceOf(msg.sender);
    toStake = toStake % (balance + 1);
    if (toStake < MINSTAKE) return; // Pre: minimal stake is required
    stake(msg.sender, toStake); // Action: token staking
    assert(staked(msg.sender) == toStake); // Post: staking amount is toStake
    assert(balanceOf(msg.sender) == balance - toStake); // Post: balance decreased
}

testStake checks some invariants on staking and also ensures that the contract's state is updated properly (e.g., allowing a user to stake at least MINSTAKE).

Dapptest

Using the "dapptest" testing mode, Echidna will report violations using certain functions following how dapptool and foundry work:

  • This mode uses any function name with one or more arguments, which will trigger a failure if they revert, except in one special case. Specifically, if the execution reverts with the special reason “FOUNDRY::ASSUME”, then the test will pass (this emulates how the assume foundry cheat code works). This pseudo-code summarizes how dapptests work:
function checkDappTest(..) public { // One or more arguments are required
  // The following statements can trigger a failure if they revert
  publicFunction(..);
  internalFunction(..);
  anotherContract.function(..);

  // The following statement will never trigger a failure
  require(.., “FOUNDRY::ASSUME”);
}
  • Functions implementing these tests do not require any particular name and are executed like any other function; therefore, their side effects are retained if they do not revert (typically, this mode is used only in stateless testing).
  • The function should NOT be payable (but this can change in the future)

Advantages:

  • Easy to implement, particularly for stateless mode.
  • Coverage is collected during the execution of these tests, so it can help to discover new failures.

Disadvantages:

  • Almost any revert will be interpreted as a failure, which is not always expected. To avoid this, you should use reverts with FOUNDRY::ASSUME or use try/catch.

Recommendation

Use dapptest mode if you are testing stateless invariants and the code will never unexpectedly revert. Avoid using it for stateful testing, as it was not designed for that (although Echidna supports it).

Stateless vs. Stateful

Any of these testing modes can be used, in either stateful (by default) or stateless mode (using --seqLen 1). In stateful mode, Echidna will maintain the state between each function call and attempt to break the invariants. In stateless mode, Echidna will discard state changes during fuzzing. There are notable differences between these two modes.

  • Stateful is more powerful and can allow breaking invariants that exist only if the contract reaches a specific state.
  • Stateless tests benefit from simpler input generation and are generally easier to use than stateful tests.
  • Stateless tests can hide issues since some of them depend on a sequence of operations that is not reachable in a single transaction.
  • Stateless mode forces resetting the EVM after each transaction or test, which is usually slower than resetting the state once every certain amount of transactions (by default, every 100 transactions).

Recommendations

For beginners, we recommend starting with Echidna in stateless mode and switching to stateful once you have a good understanding of the system's invariants.

Common Testing Approaches

Testing smart contracts is not as straightforward as testing normal binaries that you run on your local computer. This is due to the existence of multiple accounts interacting with one or many entry points. While a fuzzer can simulate the Ethereum Virtual Machine and can potentially use any account with any feature (e.g., an unlimited amount of ETH), we take care not to break some essential underlying assumptions of transactions that are impossible in Ethereum (e.g., using msg.sender as the zero address). That is why it is crucial to have a clear view of the system to test and how transactions will be simulated. We can classify the testing approach into several categories. We will start with two of them: internal and external.

Table of contents:

Internal Testing

In this testing approach, properties are defined within the contract to test, giving complete access to the internal state of the system.

contract InternalTest is System {
    function echidna_state_greater_than_X() public returns (bool) {
        return stateVar > X;
    }
}

With this approach, Echidna generates transactions from a simulated account to the target contract. This testing approach is particularly useful for simpler contracts that do not require complex initialization and have a single entry point. Additionally, properties can be easier to write, as they can access the system's internal state.

External Testing

In the external testing approach, properties are tested using external calls from a different contract. Properties are only allowed to access external/public variables or functions.

contract ExternalTest {
    constructor() public {
        addr = address(0x1234);
    }

    function echidna_state_greater_than_X() public returns (bool) {
        return System(addr).stateVar() > X;
    }
}

This testing approach is useful for dealing with contracts requiring external initialization (e.g., using Etheno). However, the method of how Echidna runs the transactions should be handled correctly, as the contract with the properties is no longer the one we want to test. Since ExternalTest defines no additional methods, running Echidna directly on this will not allow any code execution from the contract to test (no functions in ExternalTest to call besides the actual properties). In this case, there are several alternatives:

Contract wrapper: Define specific operations to "wrap" the system for testing. For each operation that we want Echidna to execute in the system to test, we add one or more functions that perform an external call to it.

contract ExternalTest {
    constructor() public {
       // addr = ...;
    }

    function method(...) public returns (...) {
        return System(addr).method();
    }

    function echidna_state_greater_than_X() public returns (bool) {
        return System(addr).stateVar() > X;
    }
}

There are two important points to consider with this approach:

  • The sender of each transaction will be the ExternalTest contract, instead of the simulated Echidna senders (e.g., 0x10000, ..). This means that the real address interacting with the system will be the External contract's address, rather than one of the Echidna senders. Please take special care if this contract needs to be provided ETH or tokens.

  • This approach is manual and can be time-consuming if there are many function operations. However, it can be useful when Echidna needs help calculating a value that cannot be randomly sampled:

contract ExternalTest {
    // ...

    function methodUsingF(..., uint256 x) public returns (...) {
       return System(addr).method(.., f(x));
    }

    ...
}

allContracts: Echidna can perform direct calls to every contract if the allContracts mode is enabled. This means that using it does not require wrapped calls. However, since every deployed contract can be called, unintended effects may occur. For example, if we have a property to ensure that the amount of tokens is limited:

contract ExternalTest {
    constructor() {
       addr = ...;
       MockERC20(...).mint(...);
    }

    function echidna_limited_supply() public returns (bool) {
       return System(addr).balanceOf(...) <= X;
    }

    ...
}

Using "mock" contracts for tokens (e.g., MockERC20) could be an issue because Echidna could call functions that are public but are only supposed to be used during the initialization, such as mint. This can be easily solved using a blacklist of functions to ignore:

filterBlacklist: true
filterFunctions: [“MockERC20.mint(uint256, address)”]

Another benefit of using this approach is that it forces the developer or auditor to write properties using public data. If an essential property cannot be defined using public data, it could indicate that users or other contracts will not be able to easily interact with the system to perform an operation or verify that the system is in a valid state.

Partial Testing

Ideally, testing a smart contract system uses the complete deployed system, with the same parameters that the developers intend to use. Testing with the real code is always preferred, even if it is slower than other methods (except for cases where it is extremely slow). However, there are many cases where, despite the complete system being deployed, it cannot be simulated because it depends on off-chain components (e.g., a token bridge). In these cases, alternative solutions must be implemented.

With partial testing, we test some of the components, ignoring or abstracting uninteresting parts such as standard ERC20 tokens or oracles. There are several ways to do this.

Isolated testing: If a component is adequately abstracted from the rest of the system, testing it can be easy. This method is particularly useful for testing stateless properties found in components that compute mathematical operations, such as mathematical libraries.

Function override: Solidity allows for function overriding, used to change the functionality of a code segment without affecting the rest of the codebase. We can use this to disable certain functions in our tests to allow testing with Echidna:

contract InternalTestOverridingSignatures is System {
    function verifySignature(...) public override returns (bool) {
        return true; // signatures are always valid
    }

    function echidna_state_greater_than_X() public returns (bool) {
        executeSomethingWithSignature(...);
        return stateVar > X;
    }
}

Model testing: If the system is not modular enough, a different approach is required. Instead of using the code as is, we will create a "model" of the system in Solidity, using mostly the original code. Although there is no defined list of steps to build a model, we can provide a generic example. Suppose we have a complex system that includes this piece of code:

contract System {
    ...

    function calculateSomething() public returns (uint256) {
        if (booleanState) {
            stateSomething = (uint256State1 * uint256State2) / 2 ** 128;
            return stateSomething / uint128State;
        }

        ...
    }
}

Where boolState, uint256State1, uint256State2, and stateSomething are state variables of our system to test. We will create a model (e.g., copy, paste, and modify the original code in a new contract), where each state variable is transformed into a parameter:

contract SystemModel {
    function calculateSomething(bool boolState, uint256 uint256State1, ...) public returns (uint256) {
        if (boolState) {
            stateSomething = (uint256State1 * uint256State2) / 2 ** 128;
            return stateSomething / uint128State;
        }
        ...
    }
}

At this point, we should be able to compile our model without any dependency on the original codebase (everything necessary should be included in the model). We can then insert assertions to detect when the returned value exceeds a certain threshold.

While developers or auditors may be tempted to quickly create tests using this technique, there are certain disadvantages when creating models:

  • The tested code can be very different from what we want to test. This can either introduce unreal issues (false positives) or hide real issues from the original code (false negatives). In the example, it is unclear if the state variables can take arbitrary values.

  • The model will have limited value if the code changes since any modification to the original model will require manually rebuilding the model.

In any case, developers should be warned that their code is difficult to test and should refactor it to avoid this issue in the future.

Filtering Functions for Fuzzing Campaigns

Table of contents:

Introduction

In this tutorial, we'll demonstrate how to filter specific functions to be fuzzed using Echidna. We'll use the following smart contract multi.sol as our target:

contract C {
    bool state1 = false;
    bool state2 = false;
    bool state3 = false;
    bool state4 = false;

    function f(uint256 x) public {
        require(x == 12);
        state1 = true;
    }

    function g(uint256 x) public {
        require(state1);
        require(x == 8);
        state2 = true;
    }

    function h(uint256 x) public {
        require(state2);
        require(x == 42);
        state3 = true;
    }

    function i() public {
        require(state3);
        state4 = true;
    }

    function reset1() public {
        state1 = false;
        state2 = false;
        state3 = false;
        return;
    }

    function reset2() public {
        state1 = false;
        state2 = false;
        state3 = false;
        return;
    }

    function echidna_state4() public returns (bool) {
        return (!state4);
    }
}

The small contract above requires Echidna to find a specific sequence of transactions to modify a certain state variable, which is difficult for a fuzzer. It's recommended to use a symbolic execution tool like Manticore in such cases. Let's run Echidna to verify this:

echidna multi.sol
...
echidna_state4: passed! 🎉
Seed: -3684648582249875403

Filtering Functions

Echidna has difficulty finding the correct sequence to test this contract because the two reset functions (reset1 and reset2) revert all state variables to false. However, we can use a special Echidna feature to either blacklist the reset functions or whitelist only the f, g, h, and i functions.

To blacklist functions, we can use the following configuration file:

filterBlacklist: true
filterFunctions: ["C.reset1()", "C.reset2()"]

Alternatively, we can whitelist specific functions by listing them in the configuration file:

filterBlacklist: false
filterFunctions: ["C.f(uint256)", "C.g(uint256)", "C.h(uint256)", "C.i()"]
  • filterBlacklist is true by default.
  • Filtering will be performed based on the full function name (contract name + "." + ABI function signature). If you have f() and f(uint256), you can specify exactly which function to filter.

Running Echidna

To run Echidna with a configuration file blacklist.yaml:

echidna multi.sol --config blacklist.yaml
...
echidna_state4: failed!💥
  Call sequence:
    f(12)
    g(8)
    h(42)
    i()

Echidna will quickly discover the sequence of transactions required to falsify the property.

Summary: Filtering Functions

Echidna can either blacklist or whitelist functions to call during a fuzzing campaign using:

filterBlacklist: true
filterFunctions: ["C.f1()", "C.f2()", "C.f3()"]
echidna contract.sol --config config.yaml
...

Depending on the value of the filterBlacklist boolean, Echidna will start a fuzzing campaign by either blacklisting C.f1(), C.f2(), and C.f3() or by only calling those functions.

How to Test Assertions with Echidna

Table of contents:

Introduction

In this short tutorial, we will demonstrate how to use Echidna to check assertions in smart contracts.

Write an Assertion

Let's assume we have a contract like this one:

contract Incrementor {
    uint256 private counter = 2 ** 200;

    function inc(uint256 val) public returns (uint256) {
        uint256 tmp = counter;
        unchecked {
            counter += val;
        }
        // tmp <= counter
        return (counter - tmp);
    }
}

We want to ensure that tmp is less than or equal to counter after returning its difference. We could write an Echidna property, but we would need to store the tmp value somewhere. Instead, we can use an assertion like this one (assert.sol):

contract Incrementor {
    uint256 private counter = 2 ** 200;

    function inc(uint256 val) public returns (uint256) {
        uint256 tmp = counter;
        unchecked {
            counter += val;
        }
        assert(tmp <= counter);
        return (counter - tmp);
    }
}

We can also use a special event called AssertionFailed with any number of parameters to inform Echidna about a failed assertion without using assert. This will work in any contract. For example:

contract Incrementor {
    event AssertionFailed(uint256);

    uint256 private counter = 2 ** 200;

    function inc(uint256 val) public returns (uint256) {
        uint256 tmp = counter;
        unchecked {
            counter += val;
        }
        if (tmp > counter) {
            emit AssertionFailed(counter);
        }
        return (counter - tmp);
    }
}

Run Echidna

To enable assertion failure testing in Echidna, you can use --test-mode assertion directly from the command line.

Alternatively, you can create an Echidna configuration file, config.yaml, with testMode set for assertion checking:

testMode: assertion

When we run this contract with Echidna, we receive the expected results:

echidna assert.sol --test-mode assertion
Analyzing contract: assert.sol:Incrementor
assertion in inc: failed!💥
  Call sequence, shrinking (2596/5000):
    inc(21711016731996786641919559689128982722488122124807605757398297001483711807488)
    inc(7237005577332262213973186563042994240829374041602535252466099000494570602496)
    inc(86844066927987146567678238756515930889952488499230423029593188005934847229952)

Seed: 1806480648350826486

As you can see, Echidna reports an assertion failure in the inc function. It is possible to add multiple assertions per function; however, Echidna cannot determine which assertion failed.

When and How to Use Assertions

Assertions can be used as alternatives to explicit properties if the conditions to check are directly related to the correct use of some operation f. Adding assertions after some code will enforce that the check happens immediately after it is executed:

function f(bytes memory args) public {
    // some complex code
    // ...
    assert(condition);
    // ...
}

In contrast, using an explicit Boolean property will randomly execute transactions, and there is no easy way to enforce exactly when it will be checked. It is still possible to use this workaround:

function echidna_assert_after_f() public returns (bool) {
    f(args);
    return (condition);
}

However, there are some issues:

  • It does not compile if f is declared as internal or external
  • It is unclear which arguments should be used to call f
  • The property will fail if f reverts

Assertions can help overcome these potential issues. For instance, they can be easily detected when calling internal or public functions:

function f(bytes memory args) public {
    // some complex code
    // ...
    g(otherArgs) // this contains an assert
    // ...
}

If g is external, then assertion failure can be only detected in Solidity 0.8.x or later.

function f(bytes memory args) public {
    // some complex code
    // ...
    contract.g(otherArgs) // this contains an assert
    // ...
}

In general, we recommend following John Regehr's advice on using assertions:

  • Do not force any side effects during the assertion checking. For instance: assert(ChangeStateAndReturn() == 1)
  • Do not assert obvious statements. For instance assert(var >= 0) where var is declared as uint256.

Finally, please do not use require instead of assert, since Echidna will not be able to detect it (but the contract will revert anyway).

Summary: Assertion Checking

The following summarizes the run of Echidna on our example (remember to use 0.7.x or older):

contract Incrementor {
    uint256 private counter = 2 ** 200;

    function inc(uint256 val) public returns (uint256) {
        uint256 tmp = counter;
        counter += val;
        assert(tmp <= counter);
        return (counter - tmp);
    }
}
echidna assert.sol --test-mode assertion
Analyzing contract: assert.sol:Incrementor
assertion in inc: failed!💥
  Call sequence, shrinking (2596/5000):
    inc(21711016731996786641919559689128982722488122124807605757398297001483711807488)
    inc(7237005577332262213973186563042994240829374041602535252466099000494570602496)
    inc(86844066927987146567678238756515930889952488499230423029593188005934847229952)

Seed: 1806480648350826486

Echidna discovered that the assertion in inc can fail if this function is called multiple times with large arguments.

How to Write Good Properties Step by Step

Table of contents:

Introduction

In this short tutorial, we will detail some ideas for writing interesting or useful properties using Echidna. At each step, we will iteratively improve our properties.

A First Approach

One of the simplest properties to write using Echidna is to throw an assertion when some function is expected to revert or return.

Let's suppose we have a contract interface like the one below:

interface DeFi {
    ERC20 t;

    function getShares(address user) external returns (uint256);

    function createShares(uint256 val) external returns (uint256);

    function depositShares(uint256 val) external;

    function withdrawShares(uint256 val) external;

    function transferShares(address to) external;
}

In this example, users can deposit tokens using depositShares, mint shares using createShares, withdraw shares using withdrawShares, transfer all shares to another user using transferShares, and get the number of shares for any account using getShares. We will start with very basic properties:

contract Test {
    DeFi defi;
    ERC20 token;

    constructor() {
        defi = DeFi(...);
        token.mint(address(this), ...);
    }

    function getShares_never_reverts() public {
        (bool b,) = defi.call(abi.encodeWithSignature("getShares(address)", address(this)));
        assert(b);
    }

    function depositShares_never_reverts(uint256 val) public {
        if (token.balanceOf(address(this)) >= val) {
            (bool b,) = defi.call(abi.encodeWithSignature("depositShares(uint256)", val));
            assert(b);
        }
    }

    function withdrawShares_never_reverts(uint256 val) public {
        if (defi.getShares(address(this)) >= val) {
            (bool b,) = defi.call(abi.encodeWithSignature("withdrawShares(uint256)", val));
            assert(b);
        }
    }

    function depositShares_can_revert(uint256 val) public {
        if (token.balanceOf(address(this)) < val) {
            (bool b,) = defi.call(abi.encodeWithSignature("depositShares(uint256)", val));
            assert(!b);
        }
    }

    function withdrawShares_can_revert(uint256 val) public {
        if (defi.getShares(address(this)) < val) {
            (bool b,) = defi.call(abi.encodeWithSignature("withdrawShares(uint256)", val));
            assert(!b);
        }
    }
}

After you have written your first version of properties, run Echidna to make sure they work as expected. During this tutorial, we will improve them step by step. It is strongly recommended to run the fuzzer at each step to increase the probability of detecting any potential issues.

Perhaps you think these properties are too low level to be useful, particularly if the code has good coverage in terms of unit tests. But you will be surprised how often an unexpected revert or return uncovers a complex and severe issue. Moreover, we will see how these properties can be improved to cover more complex post-conditions.

Before we continue, we will improve these properties using try/catch. The use of a low-level call forces us to manually encode the data, which can be error-prone (an error will always cause calls to revert). Note, this will only work if the codebase is using solc 0.6.0 or later:

function depositShares_never_reverts(uint256 val) public {
    if (token.balanceOf(address(this)) >= val) {
        try defi.depositShares(val) {
            /* not reverted */
        } catch {
            assert(false);
        }
    }
}

function depositShares_can_revert(uint256 val) public {
    if (token.balanceOf(address(this)) < val) {
        try defi.depositShares(val) {
            assert(false);
        } catch {
            /* reverted */
        }
    }
}

Enhancing Postcondition Checks

If the previous properties are passing, this means that the pre-conditions are good enough, however the post-conditions are not very precise. Avoiding reverts doesn't mean that the contract is in a valid state. Let's add some basic preconditions:

function depositShares_never_reverts(uint256 val) public {
    if (token.balanceOf(address(this)) >= val) {
        try defi.depositShares(val) {
            /* not reverted */
        } catch {
            assert(false);
        }
        assert(defi.getShares(address(this)) > 0);
    }
}

function withdrawShares_never_reverts(uint256 val) public {
    if (defi.getShares(address(this)) >= val) {
        try defi.withdrawShares(val) {
            /* not reverted */
        } catch {
            assert(false);
        }
        assert(token.balanceOf(address(this)) > 0);
    }
}

Hmm, it looks like it is not that easy to specify the value of shares or tokens obtained after each deposit or withdrawal. At least we can say that we must receive something, right?

Combining Properties

In this generic example, it is unclear if there is a way to calculate how many shares or tokens we should receive after executing the deposit or withdraw operations. Of course, if we have that information, we should use it. In any case, what we can do here is to combine these two properties into a single one to be able check more precisely its preconditions.

function deposit_withdraw_shares_never_reverts(uint256 val) public {
    uint256 original_balance = token.balanceOf(address(this));
    if (original_balance >= val) {
        try defi.depositShares(val) {
            /* not reverted */
        } catch {
            assert(false);
        }
        uint256 shares = defi.getShares(address(this));
        assert(shares > 0);
        try defi.withdrawShares(shares) {
            /* not reverted */
        } catch {
            assert(false);
        }
        assert(token.balanceOf(address(this)) == original_balance);
    }
}

The resulting property checks that calls to deposit or withdraw shares will never revert and once they execute, the original number of tokens remains the same. Keep in mind that this property should consider fees and any tolerated loss of precision (e.g. when the computation requires a division).

Final Considerations

Two important considerations for this example:

We want Echidna to spend most of the execution exploring the contract to test. So, in order to make the properties more efficient, we should avoid dead branches where there is nothing to do. That's why we can improve depositShares_never_reverts to use:

function depositShares_never_reverts(uint256 val) public {
    if (token.balanceOf(address(this)) > 0) {
        val = val % (token.balanceOf(address(this)) + 1);
        try defi.depositShares(val) { /* not reverted */ }
        catch {
            assert(false);
        }
        assert(defi.getShares(address(this)) > 0);
    } else {
        ... // code to test depositing zero tokens
    }
}

Additionally, combining properties does not mean that we will have to remove simpler ones. For instance, if we want to write withdraw_deposit_shares_never_reverts, in which we reverse the order of operations (withdraw and then deposit, instead of deposit and then withdraw), we will have to make sure defi.getShares(address(this)) can be positive. An easy way to do it is to keep depositShares_never_reverts, since this code allows Echidna to deposit tokens from address(this) (otherwise, this is impossible).

Summary: How to Write Good Properties

It is usually a good idea to start writing simple properties first and then improving them to make them more precise and easier to read. At each step, you should run a short fuzzing campaign to make sure they work as expected and try to catch issues early during the development of your smart contracts.

Using ether during a fuzzing campaign

Table of contents:

Introduction

We will see how to use ether during a fuzzing campaign. The following smart contract will be used as example:

contract C {
    function pay() public payable {
        require(msg.value == 12000);
    }

    function echidna_has_some_value() public returns (bool) {
        return (address(this).balance != 12000);
    }
}

This code forces Echidna to send a particular amount of ether as value in the pay function. Echidna will do this for each payable function in the target function (or any contract if allContracts is enabled):

$ echidna balanceSender.sol
...
echidna_has_some_value: failed!💥
  Call sequence:
    pay() Value: 0x2ee0

Echidna will show the value amount in hexadecimal.

Controlling the amount of ether in payable functions

The amount of ether to send in each payable function will be randomly selected, but with a maximum value determined by the maxValue value with a default of 100 ether per transaction:

maxValue: 100000000000000000000

This means that each transaction will contain, at most, 100 ether in value. However, there is no maximum that will be used in total. The maximum amount to receive will be determined by the number of transactions. If you are using 100 transactions (--seq-len 100), then the total amount of ether used for all the transactions will be between 0 and 100 * 100 ethers.

Keep in mind that the balance of the senders (e.g. msg.sender.balance) is a fixed value that will NOT change between transactions. This value is determined by the following config option:

balanceAddr: 0xffffffff

Controlling the amount of ether in contracts

Another approach to handle ether will be allow the testing contract to receive certain amount and then use it to send it.

contract A {
    C internal c;

    constructor() public payable {
        require(msg.value == 12000);
        c = new C();
    }

    function payToContract(uint256 toPay) public {
        toPay = toPay % (address(this).balance + 1);
        c.pay{ value: toPay }();
    }

    function echidna_C_has_some_value() public returns (bool) {
        return (address(c).balance != 12000);
    }
}

contract C {
    function pay() public payable {
        require(msg.value == 12000);
    }
}

However, if we run this directly with echidna, it will fail:

$ echidna balanceContract.sol
...
echidna: Deploying the contract 0x00a329c0648769A73afAc7F9381E08FB43dBEA72 failed (revert, out-of-gas, sending ether to an non-payable constructor, etc.):

We need to define the amount to send during the contract creation:

balanceContract: 12000

We can re-run echidna, using that config file, to obtain the expected result:

$ echidna balanceContract.sol --config balanceContract.yaml
...
echidna_C_has_some_value: failed!💥
  Call sequence:
    payToContract(12000)

Summary: Working with ether

Echidna has two options for using ether during a fuzzing campaign.

  • maxValue to set the max amount of ether per transaction
  • balanceContract to set the initial amount of ether that the testing contract receives in the constructor.

Advanced

Collecting, Visualizing, and Modifying an Echidna Corpus

Table of contents:

Introduction

In this guide, we will explore how to collect and use a corpus of transactions with Echidna. Our target is the following smart contract, magic.sol:

contract C {
    bool value_found = false;

    function magic(uint256 magic_1, uint256 magic_2, uint256 magic_3, uint256 magic_4) public {
        require(magic_1 == 42);
        require(magic_2 == 129);
        require(magic_3 == magic_4 + 333);
        value_found = true;
        return;
    }

    function echidna_magic_values() public view returns (bool) {
        return !value_found;
    }
}

This small example requires Echidna to find specific values to change a state variable. While this is challenging for a fuzzer (it is advised to use a symbolic execution tool like Manticore), we can still employ Echidna to collect corpus during this fuzzing campaign.

Collecting a corpus

To enable corpus collection, first, create a corpus directory:

mkdir corpus-magic

Next, create an Echidna configuration file called config.yaml:

corpusDir: "corpus-magic"

Now, run the tool and inspect the collected corpus:

echidna magic.sol --config config.yaml

Echidna is still unable to find the correct magic value. To understand where it gets stuck, review the corpus-magic/covered.*.txt file:

  1 | *   | contract C {
  2 |     |     bool value_found = false;
  3 |     |
  4 | *   |     function magic(uint256 magic_1, uint256 magic_2, uint256 magic_3, uint256 magic_4) public {
  5 | *r  |         require(magic_1 == 42);
  6 | *r  |         require(magic_2 == 129);
  7 | *r  |         require(magic_3 == magic_4 + 333);
  8 |     |         value_found = true;
  9 |     |         return;
 10 |     |     }
 11 |     |
 12 |     |     function echidna_magic_values() public returns (bool) {
 13 |     |         return !value_found;
 14 |     |     }
 15 |     | }

The label r on the left of each line indicates that Echidna can reach these lines, but they result in a revert. As you can see, the fuzzer gets stuck at the last require.

To find a workaround, let's examine the collected corpus. For instance, one of these files contains:

[
    {
        "_gas'": "0xffffffff",
        "_delay": ["0x13647", "0xccf6"],
        "_src": "00a329c0648769a73afac7f9381e08fb43dbea70",
        "_dst": "00a329c0648769a73afac7f9381e08fb43dbea72",
        "_value": "0x0",
        "_call": {
            "tag": "SolCall",
            "contents": [
                "magic",
                [
                    {
                        "contents": [
                            256,
                            "93723985220345906694500679277863898678726808528711107336895287282192244575836"
                        ],
                        "tag": "AbiUInt"
                    },
                    {
                        "contents": [256, "334"],
                        "tag": "AbiUInt"
                    },
                    {
                        "contents": [
                            256,
                            "68093943901352437066264791224433559271778087297543421781073458233697135179558"
                        ],
                        "tag": "AbiUInt"
                    },
                    {
                        "tag": "AbiUInt",
                        "contents": [256, "332"]
                    }
                ]
            ]
        },
        "_gasprice'": "0xa904461f1"
    }
]

This input will not trigger the failure in our property. In the next step, we will show how to modify it for that purpose.

Seeding a corpus

To handle the magic function, Echidna needs some assistance. We will copy and modify the input to utilize appropriate parameters:

cp corpus-magic/coverage/2712688662897926208.txt corpus-magic/coverage/new.txt

Modify new.txt to call magic(42,129,333,0). Now, re-run Echidna:

echidna magic.sol --config config.yaml
...
echidna_magic_values: failed!💥
  Call sequence:
    magic(42,129,333,0)

Unique instructions: 142
Unique codehashes: 1
Seed: -7293830866560616537

This time, the property fails immediately. We can verify that another covered.*.txt file is created, showing a different trace (labeled with *) that Echidna executed, which ended with a return at the end of the magic function.

  1 | *   | contract C {
  2 |     |     bool value_found = false;
  3 |     |
  4 | *   |     function magic(uint256 magic_1, uint256 magic_2, uint256 magic_3, uint256 magic_4) public {
  5 | *r  |         require(magic_1 == 42);
  6 | *r  |         require(magic_2 == 129);
  7 | *r  |         require(magic_3 == magic_4 + 333);
  8 | *   |         value_found = true;
  9 |     |         return;
 10 |     |     }
 11 |     |
 12 |     |     function echidna_magic_values() public returns (bool) {
 13 |     |         return !value_found;
 14 |     |     }
 15 |     | }

Finding Local Maximums Using Optimization Mode

Table of Contents:

Introduction

In this tutorial, we will explore how to perform function optimization using Echidna. Please ensure you have updated Echidna to version 2.0.5 or greater before proceeding.

Optimization mode is an experimental feature that enables the definition of a special function, taking no arguments and returning an int256. Echidna will attempt to find a sequence of transactions to maximize the value returned:

function echidna_opt_function() public view returns (int256) {
    // If it reverts, Echidna will assume it returned type(int256).min
    return value;
}

Optimizing with Echidna

In this example, the target is the following smart contract (opt.sol):

contract TestDutchAuctionOptimization {
    int256 maxPriceDifference;

    function setMaxPriceDifference(uint256 startPrice, uint256 endPrice, uint256 startTime, uint256 endTime) public {
        if (endTime < (startTime + 900)) revert();
        if (startPrice <= endPrice) revert();

        uint256 numerator = (startPrice - endPrice) * (block.timestamp - startTime);
        uint256 denominator = endTime - startTime;
        uint256 stepDecrease = numerator / denominator;
        uint256 currentAuctionPrice = startPrice - stepDecrease;

        if (currentAuctionPrice < endPrice) {
            maxPriceDifference = int256(endPrice - currentAuctionPrice);
        }
        if (currentAuctionPrice > startPrice) {
            maxPriceDifference = int256(currentAuctionPrice - startPrice);
        }
    }

    function echidna_opt_price_difference() public view returns (int256) {
        return maxPriceDifference;
    }
}

This small example directs Echidna to maximize a specific price difference given certain preconditions. If the preconditions are not met, the function will revert without changing the actual value.

To run this example:

echidna opt.sol --test-mode optimization --test-limit 100000 --seq-len 1 --corpus-dir corpus --shrink-limit 50000
...
echidna_opt_price_difference: max value: 1076841

  Call sequence, shrinking (42912/50000):
    setMaxPriceDifference(1349752405,1155321,609,1524172858) Time delay: 603902 seconds Block delay: 21

The resulting max value is not unique; running a longer campaign will likely yield a larger value.

Regarding the command line, optimization mode is enabled using --test-mode optimization. Additionally, we included the following tweaks:

  1. Use only one transaction (as we know the function is stateless).
  2. Use a large shrink limit to obtain a better value during input complexity minimization.

Each time Echidna is executed using the corpus directory, the last input producing the maximum value should be reused from the reproducers directory:

echidna opt.sol --test-mode optimization --test-limit 100000 --seq-len 1 --corpus-dir corpus --shrink-limit 50000
Loaded total of 1 transactions from corpus/reproducers/
Loaded total of 9 transactions from corpus/coverage/
Analyzing contract: /home/g/Code/echidna/opt.sol:TestDutchAuctionOptimization
echidna_opt_price_difference: max value: 1146878

  Call sequence:
    setMaxPriceDifference(1538793592,1155321,609,1524172858) Time delay: 523701 seconds Block delay: 49387

Identifying High Gas Consumption Transactions

Table of contents:

Introduction

This guide demonstrates how to identify transactions with high gas consumption using Echidna. The target is the following smart contract (gas.sol):

contract C {
    uint256 state;

    function expensive(uint8 times) internal {
        for (uint8 i = 0; i < times; i++) {
            state = state + i;
        }
    }

    function f(uint256 x, uint256 y, uint8 times) public {
        if (x == 42 && y == 123) {
            expensive(times);
        } else {
            state = 0;
        }
    }

    function echidna_test() public returns (bool) {
        return true;
    }
}

The expensive function can have significant gas consumption.

Currently, Echidna always requires a property to test - in this case, echidna_test always returns true. We can run Echidna to verify this:

echidna gas.sol
...
echidna_test: passed! 🎉

Seed: 2320549945714142710

Measuring Gas Consumption

To enable Echidna's gas consumption feature, create a configuration file gas.yaml:

estimateGas: true

In this example, we'll also reduce the size of the transaction sequence for easier interpretation:

seqLen: 2
estimateGas: true

Running Echidna

With the configuration file created, we can run Echidna as follows:

echidna gas.sol --config config.yaml
...
echidna_test: passed! 🎉

f used a maximum of 1333608 gas
  Call sequence:
    f(42,123,249) Gas price: 0x10d5733f0a Time delay: 0x495e5 Block delay: 0x88b2

Unique instructions: 157
Unique codehashes: 1
Seed: -325611019680165325
  • The displayed gas is an estimation provided by HEVM.

Excluding Gas-Reducing Calls

The tutorial on filtering functions to call during a fuzzing campaign demonstrates how to remove certain functions during testing. This can be crucial for obtaining accurate gas estimates. Consider the following example (example/pushpop.sol):

contract C {
    address[] addrs;

    function push(address a) public {
        addrs.push(a);
    }

    function pop() public {
        addrs.pop();
    }

    function clear() public {
        addrs.length = 0;
    }

    function check() public {
        for (uint256 i = 0; i < addrs.length; i++)
            for (uint256 j = i + 1; j < addrs.length; j++) if (addrs[i] == addrs[j]) addrs[j] = address(0);
    }

    function echidna_test() public returns (bool) {
        return true;
    }
}

With this config.yaml, Echidna can call all functions but won't easily identify transactions with high gas consumption:

echidna pushpop.sol --config config.yaml
...
pop used a maximum of 10746 gas
...
check used a maximum of 23730 gas
...
clear used a maximum of 35916 gas
...
push used a maximum of 40839 gas

This occurs because the cost depends on the size of addrs, and random calls tend to leave the array almost empty. By blacklisting pop and clear, we obtain better results (blacklistpushpop.yaml):

estimateGas: true
filterBlacklist: true
filterFunctions: ["C.pop()", "C.clear()"]
echidna pushpop.sol --config config.yaml
...
push used a maximum of 40839 gas
...
check used a maximum of 1484472 gas

Summary: Identifying high gas consumption transactions

Echidna can identify transactions with high gas consumption using the estimateGas configuration option:

estimateGas: true
echidna contract.sol --config config.yaml
...

After completing the fuzzing campaign, Echidna will report a sequence with the maximum gas consumption for each function.

Fuzzing Smart Contracts at Scale with Echidna

In this tutorial, we will review how to create a dedicated server for fuzzing smart contracts using Echidna.

Workflow:

  1. Install and set up a dedicated server
  2. Begin a short fuzzing campaign
  3. Initiate a continuous fuzzing campaign
  4. Add properties, check coverage, and modify the code if necessary
  5. Conclude the campaign

1. Install and set up a dedicated server

First, obtain a dedicated server with at least 32 GB of RAM and as many cores as possible. Start by creating a user for the fuzzing campaign. Only use the root account to create an unprivileged user:

# adduser echidna
# usermod -aG sudo echidna

Then, using the echidna user, install some basic dependencies:

sudo apt install unzip python3-pip

Next, install everything necessary to build your smart contract(s) as well as slither and echidna-parade. For example:

pip3 install solc-select
solc-select install all
pip3 install slither_analyzer
pip3 install echidna_parade

Add $PATH=$PATH:/home/echidna/.local/bin at the end of /home/echidna/.bashrc.

Afterward, install Echidna. The easiest way is to download the latest precompiled Echidna release, uncompress it, and move it to /home/echidna/.local/bin:

wget "https://github.com/crytic/echidna/releases/download/v2.0.0/echidna-test-2.0.0-Ubuntu-18.04.tar.gz"
tar -xf echidna-test-2.0.0-Ubuntu-18.04.tar.gz
mv echidna-test /home/echidna/.local/bin

2. Begin a short fuzzing campaign

Select a contract to test and provide initialization if needed. It does not have to be perfect; begin with some basic items and iterate over the results. Before starting this campaign, modify your Echidna config to define a corpus directory to use. For instance:

corpusDir: "corpus-exploration"

This directory will be automatically created, but since we are starting a new campaign, please remove the corpus directory if it was created by a previous Echidna campaign. If you don't have any properties to test, you can use:

testMode: exploration

to allow Echidna to run without any properties.

We will start a brief Echidna run (5 minutes) to check that everything looks fine. To do that, use the following config:

testLimit: 100000000000
timeout: 300 # 5 minutes

Once it runs, check the coverage file located in corpus-exploration/covered.*.txt. If the initialization is incorrect, clear the corpus-exploration directory and restart the campaign.

3. Initiate a continuous fuzzing campaign

When satisfied with the first iteration of the initialization, we can start a "continuous campaign" for exploration and testing using echidna-parade. Before starting, double-check your config file. For instance, if you added properties, do not forget to remove benchmarkMode.

echidna-parade is a tool used to launch multiple Echidna instances simultaneously while keeping track of each corpus. Each instance will be configured to run for a specific duration, with different parameters, to maximize the chance of reaching new code.

We will demonstrate this with an example, where:

  • the initial corpus is empty
  • the base config file is exploration.yaml
  • the initial instance will run for 3600 seconds (1 hour)
  • each "generation" will run for 1800 seconds (30 minutes)
  • the campaign will run in continuous mode (if the timeout is -1, it means run indefinitely)
  • there will be 8 Echidna instances per generation. Adjust this according to the number of available cores, but avoid using all of your cores if you do not want to overload your server
  • the target contract is named C
  • the file containing the contract is test.sol

Finally, we will log the stdout and stderr in parade.log and parade.err and fork the process to let it run indefinitely.

echidna-parade test.sol --config exploration.yaml --initial_time 3600 --gen_time 1800 --timeout -1 --ncores 8 --contract C > parade.log 2> parade.err &

After running this command, exit the shell to avoid accidentally killing it if your connection fails.

4. Add more properties, check coverage, and modify the code if necessary

In this step, we can add more properties while Echidna explores the contracts. Keep in mind that you should avoid changing the contracts' ABI (otherwise, the quality of the corpus will degrade).

Additionally, we can tweak the code to improve coverage, but before starting, we need to know how to monitor our fuzzing campaign. We can use this command:

watch "grep 'COLLECTING NEW COVERAGE' parade.log | tail -n 30"

When new coverage is found, you will see something like this:

COLLECTING NEW COVERAGE: parade.181140/gen.30.10/corpus/coverage/-3538310549422809236.txt
COLLECTING NEW COVERAGE: parade.181140/gen.35.9/corpus/coverage/5960152130200926175.txt
COLLECTING NEW COVERAGE: parade.181140/gen.35.10/corpus/coverage/3416698846701985227.txt
COLLECTING NEW COVERAGE: parade.181140/gen.36.6/corpus/coverage/-3997334938716772896.txt
COLLECTING NEW COVERAGE: parade.181140/gen.37.7/corpus/coverage/323061126212903141.txt
COLLECTING NEW COVERAGE: parade.181140/gen.37.6/corpus/coverage/6733481703877290093.txt

You can verify the corresponding covered file, such as parade.181140/gen.37.6/corpus/covered.1615497368.txt.

For examples on how to help Echidna improve its coverage, please review the improving coverage tutorial.

To monitor failed properties, use this command:

watch "grep 'FAIL' parade.log | tail -n 30"

When failed properties are found, you will see something like this:

NEW FAILURE: assertion in f: failed!💥
parade.181140/gen.179.0 FAILED
parade.181140/gen.179.3 FAILED
parade.181140/gen.180.2 FAILED
parade.181140/gen.180.4 FAILED
parade.181140/gen.180.3 FAILED
...

5. Conclude the campaign

When satisfied with the coverage results, you can terminate the continuous campaign using:

killall echidna-parade echidna

How to Test Bytecode-Only Contracts

Table of contents:

Introduction

In this tutorial, you'll learn how to fuzz a contract without any provided source code. The technique can also be used to perform differential fuzzing (i.e., compare multiple implementations) between a Solidity contract and a Vyper contract.

Consider the following bytecode:

608060405234801561001057600080fd5b506103e86000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020819055506103e86001819055506101fa8061006e6000396000f3fe608060405234801561001057600080fd5b50600436106100415760003560e01c806318160ddd1461004657806370a0823114610064578063a9059cbb146100bc575b600080fd5b61004e61010a565b6040518082815260200191505060405180910390f35b6100a66004803603602081101561007a57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050610110565b6040518082815260200191505060405180910390f35b610108600480360360408110156100d257600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190505050610128565b005b60015481565b60006020528060005260406000206000915090505481565b806000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008282540392505081905550806000808473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008282540192505081905550505056fe

For which we only know the ABI:

interface Target {
    function totalSupply() external returns (uint256);

    function balanceOf(address) external returns (uint256);

    function transfer(address, uint256) external;
}

We want to test if it is possible to have more tokens than the total supply.

Proxy Pattern

Since we don't have the source code, we can't directly add the property to the contract. Instead, we'll use a proxy contract:

interface Target {
    function totalSupply() external returns (uint256);

    function balanceOf(address) external returns (uint256);

    function transfer(address, uint256) external;
}

contract TestBytecodeOnly {
    Target target;

    constructor() {
        address targetAddress;
        // init bytecode
        bytes
            memory targetCreationBytecode = hex"608060405234801561001057600080fd5b506103e86000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020819055506103e86001819055506101fa8061006e6000396000f3fe608060405234801561001057600080fd5b50600436106100415760003560e01c806318160ddd1461004657806370a0823114610064578063a9059cbb146100bc575b600080fd5b61004e61010a565b6040518082815260200191505060405180910390f35b6100a66004803603602081101561007a57600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050610110565b6040518082815260200191505060405180910390f35b610108600480360360408110156100d257600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190505050610128565b005b60015481565b60006020528060005260406000206000915090505481565b806000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008282540392505081905550806000808473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008282540192505081905550505056fe";

        uint256 size = targetCreationBytecode.length;

        assembly {
            targetAddress := create(0, add(targetCreationBytecode, 0x20), size) // Skip the 32 bytes encoded length.
        }

        target = Target(targetAddress);
    }

    function transfer(address to, uint256 amount) public {
        target.transfer(to, amount);
    }

    function echidna_test_balance() public returns (bool) {
        return target.balanceOf(address(this)) <= target.totalSupply();
    }
}

The proxy:

  • Deploys the bytecode in its constructor
  • Has one function that calls the target's transfer function
  • Has one Echidna property target.balanceOf(address(this)) <= target.totalSupply()

Running Echidna

echidna bytecode_only.sol --contract TestBytecodeOnly
echidna_test_balance: failed!💥
  Call sequence:
    transfer(0x0,1002)

Here, Echidna found that by calling transfer(0, 1002) anyone can mint tokens.

Target Source Code

The actual source code of the target is:

contract C {
    mapping(address => uint256) public balanceOf;
    uint256 public totalSupply;

    constructor() public {
        balanceOf[msg.sender] = 1000;
        totalSupply = 1000;
    }

    function transfer(address to, uint256 amount) public {
        balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;
    }
}

Echidna correctly found the bug: lack of overflow checks in transfer.

Differential Fuzzing

Consider the following Vyper and Solidity contracts:

@view
@external
def my_func(a: uint256, b: uint256, c: uint256) -> uint256:
    return a * b / c
contract SolidityVersion {
    function my_func(uint256 a, uint256 b, uint256 c) public view {
        return (a * b) / c;
    }
}

We can test that they always return the same values using the proxy pattern:

interface Target {
    function my_func(uint256, uint256, uint256) external returns (uint256);
}

contract SolidityVersion {
    Target target;

    constructor() public {
        address targetAddress;

        // vyper bytecode
        bytes
            memory targetCreationBytecode = hex"61007756341561000a57600080fd5b60043610156100185761006d565b600035601c52630ff198a3600051141561006c57600435602435808202821582848304141761004657600080fd5b80905090509050604435808061005b57600080fd5b82049050905060005260206000f350005b5b60006000fd5b61000461007703610004600039610004610077036000f3";

        uint256 size = targetCreationBytecode.length;

        assembly {
            targetAddress := create(0, add(targetCreationBytecode, 0x20), size) // Skip the 32 bytes encoded length.
        }
        target = Target(targetAddress);
    }

    function test(uint256 a, uint256 b, uint256 c) public returns (bool) {
        assert(my_func(a, b, c) == target.my_func(a, b, c));
    }

    function my_func(uint256 a, uint256 b, uint256 c) internal view returns (uint256) {
        return (a * b) / c;
    }
}

Here we run Echidna with the assertion mode:

echidna  vyper.sol --config config.yaml --contract SolidityVersion --test-mode assertion
assertion in test: passed! 🎉

Generic Proxy Code

Adapt the following code to your needs:

interface Target {
    // public/external functions
}

contract TestBytecodeOnly {
    Target target;

    constructor() public {
        address targetAddress;
        // init bytecode
        bytes memory targetCreationBytecode = hex"";

        uint256 size = targetCreationBytecode.length;

        assembly {
            targetAddress := create(0, add(targetCreationBytecode, 0x20), size) // Skip the 32 bytes encoded length.
        }
        target = Target(targetAddress);
    }

    // Add helper functions to call the target's functions from the proxy

    function echidna_test() public returns (bool) {
        // The property to test
    }
}

Summary: Testing Contracts Without Source Code

Echidna can fuzz contracts without source code using a proxy contract. This technique can also be used to compare implementations written in Solidity and Vyper.

How and when to use cheat codes

Table of contents:

Introduction

When testing smart contracts in Solidity itself, it can be helpful to use cheat codes in order to overcome some of the limitations of the EVM/Solidity. Cheat codes are special functions that allow to change the state of the EVM in ways that are not posible in production. These were introduced by Dapptools in hevm and adopted (and expanded) in other projects such as Foundry.

Cheat codes available in Echidna

Echidna supports all cheat codes that are available in hevm. These are documented here: https://hevm.dev/controlling-the-unit-testing-environment.html#cheat-codes. If a new cheat code is added in the future, Echidna only needs to update the hevm version and everything should work out of the box.

As an example, the prank cheat code is able to set the msg.sender address in the context of the next external call:

interface IHevm {
    function prank(address) external;
}

contract TestPrank {
  address constant HEVM_ADDRESS = 0x7109709ECfa91a80626fF3989D68f67F5b1DD12D;
  IHevm hevm = IHevm(HEVM_ADDRESS);
  Contract c = ...

  function prankContract() public payable {
    hevm.prank(address(0x42424242);
    c.f(); // `c` will be called with `msg.sender = 0x42424242`
  }
}

A specific example on the use of sign cheat code is available here in our documentation.

Risks of cheat codes

While we provide support for the use of cheat codes, these should be used responsibly. Consider that:

  • Cheat codes can break certain assumptions in Solidity. For example, the compiler assumes that block.number is constant during a transaction. There are reports of the optimizer interfering with (re)computation of the block.number or block.timestamp, which can generate incorrect tests when using cheat codes.

  • Cheat codes can introduce false positives on the testing. For instance, using prank to simulate calls from a contract can allow transactions that are not possible in the blockchain.

  • Using too many cheat codes:

    • can be confusing or error-prone. Certain cheat code like prank allow to change caller in the next external call: It can be difficult to follow, in particular if it is used in internal functions or modifiers.
    • will create a dependency of your code with the particular tool or cheat code implementation: It can cause produce migrations to other tools or reusing the test code to be more difficult than expected.

Using HEVM Cheats To Test Permit

Introduction

EIP 2612 introduces the function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) to the ERC20 ABI. This function takes in signature parameters generated through ECDSA, combined with the EIP 712 standard for typed data hashing, and recovers the author of the signature through ecrecover(). It then sets allowances[owner][spender] to value.

Uses

This method presents a new way of allocating allowances, as signatures can be computed off-chain and passed to a contract. It allows a relayer to pay the entire gas fee of the permit transaction in exchange for a fee, enabling completely gasless transactions for a user. Furthermore, this removes the typical approve() -> transferFrom() pattern that forces users to send two transactions instead of just one through this new method.

Note that for the permit function to work, a valid signature is needed. This example will demonstrate how we can use hevm's sign cheatcode to sign data with a private key. More generally, you can use this cheatcode to test anything that requires valid signatures.

Example

We use Solmate’s implementation of the ERC20 standard that includes the permit function. Observe that there are also values for the PERMIT_TYPEHASH and a mapping(address -> uint256) public nonces. The former is part of the EIP712 standard, and the latter is used to prevent signature replay attacks.

In our TestDepositWithPermit contract, we need to have the signature signed by an owner for validation. To accomplish this, we can use hevm’s sign cheatcode, which takes in a message and a private key and creates a valid signature. For this example, we use the private key 0x02, and the following signed message representing the permit signature following the EIP 712:

keccak256(
    abi.encodePacked(
        "\x19\x01",
        asset.DOMAIN_SEPARATOR(),
        keccak256(
            abi.encode(
                keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"),
                owner,
                spender,
                assetAmount,
                asset.nonces(owner),
                block.timestamp
            )
        )
    )
);

The helper function getSignature(address owner, address spender, uint256 assetAmount) returns a valid signature generated via the sign cheatcode. Note that the sign cheatcode exposes the private key, so it is best to use dummy keys when testing. Our keypair data was taken from this site. To test the signature, we will mint a random amount to the OWNER address, the address corresponding to the private key 0x02, which was the signer of the permit signature. We then check whether we can use that signature to transfer the owner’s tokens to ourselves.

First, we call permit() on our Mock ERC20 token with the signature generated in getSignature(), and then call transferFrom(). If our permit request and transfer are successful, our balance of the mock ERC20 should increase by the amount permitted, and the OWNER's balance should decrease as well. For simplicity, we'll transfer all the minted tokens so that the OWNER's balance will be 0, and our balance will be amount.

Code

The complete example code can be found here.

End-to-End Testing with Echidna (Part I)

When smart contracts require complex initialization and the time to do so is short, we want to avoid manually recreating a deployment for a fuzzing campaign with Echidna. That's why we have a new approach for testing using Echidna based on the deployments and execution of tests directly from Ganache.

Requirements:

This approach needs a smart contract project with the following constraints:

  • It should use Solidity; Vyper is not supported since Slither/Echidna is not very effective at running these (e.g. no AST is included).
  • It should have tests or at least a complete deployment script.
  • It should work with Slither. If it fails, please report the issue.

For this tutorial, we used the Drizzle-box example.

Getting Started:

Before starting, make sure you have the latest releases from Echidna and Etheno installed.

Then, install the packages to compile the project:

git clone https://github.com/truffle-box/drizzle-box
cd drizzle-box
npm i truffle

If ganache is not installed, add it manually. In our example, we will run:

npm -g i ganache

Other projects using Yarn will require:

yarn global add ganache

Ensure that $ ganache --version outputs ganache v7.3.2 or greater.

It is also important to select one test script from the available tests. Ideally, this test will deploy all (or most) contracts, including mock/test ones. For this example, we are going to examine the SimpleStorage contract:

contract SimpleStorage {
    event StorageSet(string _message);

    uint256 public storedData;

    function set(uint256 x) public {
        storedData = x;

        emit StorageSet("Data stored successfully!");
    }
}

This small contract allows the storedData state variable to be set. As expected, we have a unit test that deploys and tests this contract (simplestorage.js):

const SimpleStorage = artifacts.require("SimpleStorage");

contract("SimpleStorage", (accounts) => {
    it("...should store the value 89.", async () => {
        const simpleStorageInstance = await SimpleStorage.deployed();

        // Set value of 89
        await simpleStorageInstance.set(89, { from: accounts[0] });

        // Get stored value
        const storedData = await simpleStorageInstance.storedData.call();

        assert.equal(storedData, 89, "The value 89 was not stored.");
    });
});

Capturing Transactions

Before starting to write interesting properties, it is necessary to collect an Etheno trace to replay it inside Echidna:

First, start Etheno:

etheno --ganache --ganache-args="--miner.blockGasLimit 10000000" -x init.json

By default, the following Ganache arguments are set via Etheno:

  • -d: Ganache will use a pre-defined, deterministic seed to create all accounts.
  • --chain.allowUnlimitedContractSize: Allows unlimited contract sizes while debugging. This is set so that there is no size limitation on the contracts that are going to be deployed.
  • -p <port_num>: The port_num will be set to (1) the value of --ganache-port or (2) Etheno will choose the smallest port number higher than the port number on which Etheno’s JSON RPC server is running.

NOTE: If you are using Docker to run Etheno, the commands should be:

docker run -it -p 8545:8545 -v ~/etheno:/home/etheno/ trailofbits/etheno
(you will now be working within the Docker instance)
etheno --ganache --ganache-args="--miner.blockGasLimit 10000000" -x init.json
  • The -p in the first command publishes (i.e., exposes) port 8545 from inside the Docker container out to port 8545 on the host.
  • The -v in the first command maps a directory from inside the Docker container to one outside the Docker container. After Etheno exits, the init.json file will now be in the ~/etheno folder on the host.

Note that if the deployment fails to complete successfully due to a ProviderError: exceeds block gas limit exception, increasing the --miner.blockGasLimit value can help. This is especially helpful for large contract deployments. Learn more about the various Ganache command-line arguments that can be set by clicking here.

Additionally, if Etheno fails to produce any output, it may have failed to execute ganache under-the-hood. Check if ganache (with the associated command-line arguments) can be executed correctly from your terminal without the use of Etheno.

Meanwhile, in another terminal, run one test or the deployment process. How to run it depends on how the project was developed. For instance, for Truffle, use:

truffle test test/test.js

For Buidler:

buidler test test/test.js --network localhost

In the Drizzle example, we will run:

truffle test test/simplestorage.js --network develop.

After Etheno finishes, gently kill it using Ctrl+C (or Command+C on Mac). It will save the init.json file. If your test fails for some reason, or you want to run a different one, restart Etheno and re-run the test.

Writing and Running a Property

Once we have a JSON file with saved transactions, we can verify that the SimpleStorage contract is deployed at 0x871DD7C2B4b25E1Aa18728e9D5f2Af4C4e431f5c. We can easily write a contract in contracts/crytic/E2E.sol with a simple property to test it:

import "../SimpleStorage.sol";

contract E2E {
    SimpleStorage st = SimpleStorage(0x871DD7C2B4b25E1Aa18728e9D5f2Af4C4e431f5c);

    function crytic_const_storage() public returns (bool) {
        return st.storedData() == 89;
    }
}

For large, multi-contract deployments, using console.log to print out the deployed contract addresses can be valuable in quickly setting up the Echidna testing contract.

This simple property checks if the stored data remains constant. To run it, you will need the following Echidna config file (echidna.yaml):

prefix: crytic_
initialize: init.json
allContracts: true
cryticArgs: ["--truffle-build-directory", "app/src/contracts/"] # needed by Drizzle

Then, running Echidna shows the results immediately:

echidna . --contract E2E --config echidna.yaml
...
crytic_const_storage: failed!💥
  Call sequence:
    (0x871dd7c2b4b25e1aa18728e9d5f2af4c4e431f5c).set(0) from: 0x0000000000000000000000000000000000010000

For this last step, make sure you are using . as a target for echidna. If you use the path to the E2E.sol file instead, Echidna will not be able to get information from all the deployed contracts to call the set(uint256) function, and the property will never fail.

Key Considerations:

When using Etheno with Echidna, note that there are two edge cases that may cause unexpected behavior:

  1. Function calls that use ether: The accounts created and used for testing in Ganache are not the same as the accounts used to send transactions in Echidna. Thus, the account balances of the Ganache accounts do not carry over to the accounts used by Echidna. If there is a function call logged by Etheno that requires the transfer of some ether from an account that exists in Ganache, this call will fail in Echidna.
  2. Fuzz tests that rely on block.timestamp: The concept of time is different between Ganache and Echidna. Echidna always starts with a fixed timestamp, while Etheno will use Ganache's concept of time. This means that assertions or requirements in a fuzz test that rely on timestamp comparisons/evaluations may fail in Echidna.

In the next part of this tutorial, we will explore how to easily find where contracts are deployed with a specific tool based on Slither. This will be useful if the deployment process is complex, and we need to test a particular contract.

Understanding and using allContracts in Echidna

Table of contents:

Introduction

This tutorial is written as a hands-on guide to using allContracts testing in Echidna. You will learn what allContracts testing is, how to use it in your tests, and what to expect from its usage.

This feature used to be called multi-abi but it was later renamed to allContracts in Echidna 2.1.0. As expected, this version or later is required for this tutorial.

What is allContracts testing?

It is a testing mode that allows Echidna to call functions from any contract not directly under test. The ABI for the contract must be known, and it must have been deployed by the contract under test.

When and how to use allContracts

By default, Echidna calls functions from the contract to be analyzed, sending the transactions randomly from addresses 0x10000, 0x20000 and 0x30000.

In some systems, the user has to interact with other contracts prior to calling a function on the fuzzed contract. A common example is when you want to provide liquidity to a DeFi protocol, you will first need to approve the protocol for spending your tokens. This transaction has to be initiated from your account before actually interacting with the protocol contract.

A fuzzing campaign meant to test this example protocol contract won't be able to modify users allowances, therefore most of the interactions with the protocol won't be tested correctly.

This is where allContracts testing is useful: It allows Echidna to call functions from other contracts (not just from the contract under test), sending the transactions from the same accounts that will interact with the target contract.

Run Echidna

We will use a simple example to show how allContracts works. We will be using two contracts, Flag and EchidnaTest, both available in allContracts.sol.

The Flag contract contains a boolean flag that is only set if flip() is called, and a getter function that returns the value of the flag. For now, ignore test_fail(), we will talk about this function later.

contract Flag {
    bool flag = false;

    function flip() public {
        flag = !flag;
    }

    function get() public returns (bool) {
        return flag;
    }

    function test_fail() public {
        assert(false);
    }
}

The test harness will instantiate a new Flag, and the invariant under test will be that f.get() (that is, the boolean value of the flag) is always false.

contract EchidnaTest {
    Flag f;

    constructor() {
        f = new Flag();
    }

    function test_flag_is_false() public {
        assert(f.get() == false);
    }
}

In a non allContracts fuzzing campaign, Echidna is not able to break the invariant, because it only interacts with EchidnaTest functions. However, if we use the following configuration file, enabling allContracts testing, the invariant is broken. You can access allContracts.yaml here.

testMode: assertion
testLimit: 50000
allContracts: true

To run the Echidna tests, run echidna allContracts.sol --contract EchidnaTest --config allContracts.yaml from the example directory. Alternatively, you can specify --all-contracts in the command line instead of using a configuration file.

Example run with allContracts set to false

echidna allContracts.sol --contract EchidnaTest --config allContracts.yaml
Analyzing contract: building-secure-contracts/program-analysis/echidna/example/allContracts.sol:EchidnaTest
test_flag_is_false():  passed! 🎉
AssertionFailed(..):  passed! 🎉

Unique instructions: 282
Unique codehashes: 2
Corpus size: 2
Seed: -8252538430849362039

Example run with allContracts set to true

echidna allContracts.sol --contract EchidnaTest --config allContracts.yaml
Analyzing contract: building-secure-contracts/program-analysis/echidna/example/allContracts.sol:EchidnaTest
test_flag_is_false(): failed!💥
  Call sequence:
    flip()
    flip()
    flip()
    test_flag_is_false()

Event sequence: Panic(1)
AssertionFailed(..):  passed! 🎉

Unique instructions: 368
Unique codehashes: 2
Corpus size: 6
Seed: -6168343983565830424

Use cases and conclusions

Testing with allContracts is a useful tool for complex systems that require the user to interact with more than one contract, as we mentioned earlier. Another use case is for deployed contracts that require interactions to be initiated by specific addresses: for those, specifying the sender configuration setting allows to send the transactions from the correct account.

A side-effect of using allContracts is that the search space grows with the number of functions that can be called. This, combined with high values of sequence lengths, can make the fuzzing test not so thorough, because the dimension of the search space is simply too big to reasonably explore. Finally, adding more functions as fuzzing candidates makes the campaigns to take up more execution time.

A final remark is that allContracts testing in assertion mode ignores all assert failures from the contracts not under test. This is shown in Flag.test_fail() function: even though it explicitly asserts false, the Echidna test ignores it.

Working with external libraries

Table of contents:

Introduction

Solidity support two types of libraries (see the documentation):

  • If all the functions are internal, the library is compiled into bytecode and added into the contracts that use it.
  • If there are some external functions, the library should be deployed into some address. Finally, the bytecode calling the library should be linked.

The following is only needed if your codebase uses libraries that need to be linked.

Example code

For this tutorial, we will use the metacoin example. Let's start compiling it:

$ git clone https://github.com/truffle-box/metacoin-box
$ cd metacoin-box
$ npm i

Deploying libraries

Libraries are contracts that need to be deployed first. Fortunately, Echidna allows us to do that easily, using the deployContracts option. In the metacoin example, we can use:

deployContracts: [["0x1f", "ConvertLib"]]

The address where the library should be deployed is arbitrary, but it should be the same as the one in the used during the linking process.

Linking libraries

Before a contract can use a deployed library, its bytecode requires to be linked (e.g set the address that points to the deployed library contract). Normally, a compilation framework (e.g. truffle) will take care of this. However, in our case, we will use crytic-compile, since it is easier to handle all cases from different frameworks just adding one new argument to pass to crytic-compile from Echidna:

cryticArgs: ["--compile-libraries=(ConvertLib,0x1f)"]

Going back to the example, if we have both config options in a single config file (echidna.yaml), we can run the metacoin contract in exploration mode:

$ echidna . --test-mode exploration --corpus-dir corpus --contract MetaCoin --config echidna.yaml

We can use the coverage report to verify that function using the library (getBalanceInEth) is not reverting:

 28 | *   |     function getBalanceInEth(address addr) public view returns(uint){
 29 | *   |             return ConvertLib.convert(getBalance(addr),2);
 30 |     |     }

Summary

Working with libraries in Echidna is supported. It involves to deploy the library to a particular address using deployContracts and then asking crytic-compile to link the bytecode with the same address using --compile-libraries command line.

On-chain fuzzing with state forking

Table of contents:

Introduction

Echidna recently added support for state network forking, starting from the 2.1.0 release. In a few words, our fuzzer can run a campaign starting with an existing blockchain state provided by an external RPC service (Infura, Alchemy, local node, etc). This enables users to speed up the fuzzing setup when using already deployed contracts.

Example

In the following contract, an assertion will fail if the call to Compound ETH mint function succeeds and the balance of the contract increases.

interface IHevm {
    function warp(uint256 newTimestamp) external;

    function roll(uint256 newNumber) external;
}

interface Compound {
    function mint() external payable;

    function balanceOf(address) external view returns (uint256);
}

contract TestCompoundEthMint {
    address constant HEVM_ADDRESS = 0x7109709ECfa91a80626fF3989D68f67F5b1DD12D;
    IHevm hevm = IHevm(HEVM_ADDRESS);
    Compound comp = Compound(0x4Ddc2D193948926D02f9B1fE9e1daa0718270ED5);

    constructor() {
        hevm.roll(16771449); // sets the correct block number
        hevm.warp(1678131671); // sets the expected timestamp for the block number
    }

    function assertNoBalance() public payable {
        require(comp.balanceOf(address(this)) == 0);
        comp.mint{ value: msg.value }();
        assert(comp.balanceOf(address(this)) == 0);
    }
}

In order to use this feature, the user needs to specify the RPC endpoint for Echidna to use before running the fuzzing campaign. This requires using the ECHIDNA_RPC_URL and ECHIDNA_RPC_BLOCK environment variables:

$ ECHIDNA_RPC_URL=http://.. ECHIDNA_RPC_BLOCK=16771449 echidna compound.sol --test-mode assertion --contract TestCompoundEthMint
...
assertNoBalance(): failed!💥
  Call sequence, shrinking (885/5000):
    assertNoBalance() Value: 0xd0411a5

Echidna will query contract code or storage slots as needed from the provided RPC node. You can press the key f key to see which contracts/slots are fetched.

Please note that only the state specified in the ECHIDNA_RPC_BLOCK will be fetched. If Echidna increases the block number, it is all just simulated locally but its state is still loaded from the initially set RPC block.

Corpus and RPC cache

If a corpus directory is used (e.g. --corpus-dir corpus), Echidna will save the fetched information inside the cache directory. This will speed up subsequent runs, since the data does not need to be fetched from the RPC. It is recommended to use this feature, in particular if the testing is performed as part of the CI tests.

$ ls corpus/cache/
block_16771449_fetch_cache_contracts.json  block_16771449_fetch_cache_slots.json

Coverage and Etherscan integration

When the fuzzing campaign is over, if the source code mapping of any executed on-chain contract is available on Etherscan, it will be fetched automatically for the coverage report. Optionally, an Etherscan key can be provided using the ETHERSCAN_API_KEY environment variable.

Fetching Solidity source for contract at address 0x4Ddc2D193948926D02f9B1fE9e1daa0718270ED5... Retrying (5 left). Error: Max rate limit reached, please use API Key for higher rate limit
Retrying (4 left). Error: Max rate limit reached, please use API Key for higher rate limit
Retrying (3 left). Error: Max rate limit reached, please use API Key for higher rate limit
Success!
Fetching Solidity source map for contract at address 0x4Ddc2D193948926D02f9B1fE9e1daa0718270ED5... Error!

While the source code for the cETH contract is available, their source maps are NOT. In order to generate the coverage report for a fetched contract, both source code and source mapping should be available. In that case, there will be a new directory inside the corpus directory to show coverage for each contract that was fetched. In any case, the coverage report will be always available for the user-provided contracts, such as this one:

20 |     |
21 | *r  |   function assertNoBalance() public payable {
22 | *r  |     require(comp.balanceOf(address(this)) == 0);
23 | *r  |     comp.mint{value: msg.value}();
24 | *r  |     assert(comp.balanceOf(address(this)) == 0);
25 |     |   }

Interacting with off-chain data using the ffi cheatcode

Introduction

It is possible for Echidna to interact with off-chain data by means of the ffi cheatcode. This function allows the caller to execute an arbitrary command on the system running Echidna and read its output, enabling the possibility of getting external data into a fuzzing campaign.

A word of caution

In general, the usage of cheatcodes is not encouraged, since manipulating the EVM execution environment can lead to unpredictable results and false positives or negatives in fuzzing tests.

This piece of advice becomes more critical when using ffi. This cheatcode basically allows arbitrary code execution on the host system, so it's not just the EVM execution environment that can be manipulated. Running malicious or untrusted tests with ffi can have disastrous consequences.

The usage of this cheatcode should be extremely limited, well documented, and only reserved for cases where there is not a secure alternative.

Pre-requisites

If reading the previous section didn't scare you enough and you still want to use ffi, you will need to explicitly tell Echidna to allow the cheatcode in the tests. This safety measure makes sure you don't accidentally execute ffi code.

To enable the cheatcode, set the allowFFI flag to true in your Echidna configuration file:

allowFFI: true

Uses

Some of the use cases for ffi are:

  • Making prices or other information available on-chain during a fuzzing campaign. For example, you can use ffi to feed an oracle with "live" data.
  • Get randomness in a test. As you know, there is no randomness source on-chain, so using this cheatcode you can get a random value from the device running the fuzz tests.
  • Integrate with algorithms not ported to Solidity language, or perform comparisons between two implementations. Some examples for this item include signing and hashing, or custom calculations algorithms.

Example: Call an off-chain program and read its output

This example will show how to create a simple call to an external executable, passing some values as parameters, and read its output. Keep in mind that the return values of the called program should be an abi-encoded data chunk that can be later decoded via abi.decode(). No newlines are allowed in the return values.

Before digging into the example, there's something else to keep in mind: When interacting with external processes, you will need to convert from Solidity data types to string, to pass values as arguments to the off-chain executable. You can use the crytic/properties toString helpers for converting.

For the example we will be creating a python example script that returns a random uint256 value and a bytes32 hash calculated from an integer input value. This doesn't represent a "useful" use case, but will be enough to show how the ffi cheatcode is used. Finally, we won't perform sanity checks for data types or values, we will just assume the input data will be correct.

This script was tested with Python 3.11, Web3 6.0.0 and eth-abi 4.0.0. Some functions had different names in prior versions of the libraries.

import sys
import secrets
from web3 import Web3
from eth_abi import encode

# Usage: python3 script.py number
number = int(sys.argv[1])

# Generate a 10-byte random number
random = int(secrets.token_hex(10), 16)

# Generate the keccak hash of the input value
hashed = Web3.solidity_keccak(['uint256'], [number])

# ABI-encode the output
abi_encoded = encode(['uint256', 'bytes32'], [random, hashed]).hex()

# Make sure that it doesn't print a newline character
print("0x" + abi_encoded, end="")

You can test this program with various inputs and see what the output is. If it works correctly, the program should output a 512-bit hex string that is the ABI-encoded representation of a 256-bit integer followed by a bytes32.

Now let's create the Solidity contract that will be run by Echidna to interact with the previous script.

pragma solidity ^0.8.0;

// HEVM helper
import "@crytic/properties/contracts/util/Hevm.sol";

// Helpers to convert uint256 to string
import "@crytic/properties/contracts/util/PropertiesHelper.sol";

contract TestFFI {
    function test_ffi(uint256 number) public {
        // Prepare the array of executable and parameters
        string[] memory inp = new string[](3);
        inp[0] = "python3";
        inp[1] = "script.py";
        inp[2] = PropertiesLibString.toString(number);

        // Call the program outside the EVM environment
        bytes memory res = hevm.ffi(inp);

        // Decode the return values
        (uint256 random, bytes32 hashed) = abi.decode(res, (uint256, bytes32));

        // Make sure the return value is the expected
        bytes32 hashed_solidity = keccak256(abi.encodePacked(number));
        assert(hashed_solidity == hashed);
    }
}

The minimal configuration file for this test is the following:

testMode: "assertion"
allowFFI: true

Fuzzing Tips

The following tips will help enhance the efficiency of Echidna when fuzzing:

Filtering Inputs

Using % is more efficient for filtering input values than adding require or if statements. For instance, when fuzzing an operation(uint256 index, ..) with index expected to be less than 10**18, use the following:

function operation(uint256 index) public {
    index = index % 10 ** 18;
    // ...
}

Using require(index <= 10**18) instead would result in many generated transactions reverting, which would slow down the fuzzer.

To define a minimum and maximum range, you can adapt the code like this:

function operation(uint256 balance) public {
    balance = MIN_BALANCE + (balance % (MAX_BALANCE - MIN_BALANCE));
    // ...
}

This ensures that the balance value stays between MIN_BALANCE and MAX_BALANCE, without discarding any generated transactions. While this speeds up the exploration process, it might prevent some code paths from being tested. To address this issue, you can provide two functions:

function operation(uint256 balance) public {
    // ...
}

function safeOperation(uint256 balance) public {
    balance = MIN_BALANCE + (balance % (MAX_BALANCE - MIN_BALANCE)); // safe balance
    // ...
}

Echidna can then use either of these functions, allowing it to explore both safe and unsafe usage of the input data.

Handling Dynamic Arrays

When using a dynamic array as input, Echidna restricts its size to 32 elements:

function operation(uint256[] calldata data) public {
    // ...
}

This is because deserializing dynamic arrays can be slow and may consume a significant amount of memory. Additionally, dynamic arrays can be difficult to mutate. However, Echidna includes specific mutators to remove/repeat elements or truncate elements, which it performs using the collected corpus. Generally, we recommend using push(...) and pop() functions to handle dynamic arrays used as inputs:

contract DataHandler {
    uint256[] data;

    function push(uint256 x) public {
        data.push(x);
    }

    function pop() public {
        data.pop();
    }

    function operation() public {
        // Use of `data`
    }
}

This approach works well for testing arrays with a small number of elements. However, it can introduce an exploration bias: since push and pop functions are selected with equal probability, the chances of creating large arrays (e.g., more than 64 elements) are very low. One workaround is to blacklist the pop() function during a brief campaign:

filterFunctions: ["C.pop()"]

This should suffice for small-scale testing. A more comprehensive solution involves swarm testing, a technique that performs long testing campaigns with randomized configurations. In the context of Echidna, swarm testing is executed using different configuration files, which blacklist random contract functions before testing. We offer swarm testing and scalability through echidna-parade, our dedicated tool for fuzzing smart contracts. A tutorial on using echidna-parade can be found here.

Frequently Asked Questions about Echidna

This list provides answers to frequently asked questions related to the usage of Echidna. If you find this information challenging to understand, please ensure you have already reviewed all the other Echidna documentation.

Echidna fails to start or compile my contract; what should I do?

Begin by testing if crytic-compile can compile your contracts. If you are using a compilation framework such as Truffle or Hardhat, use the command:

crytic-compile .

and check for any errors. If there is an unexpected error, please report it in the crytic-compile issue tracker.

If crytic-compile works fine, test slither to see if there are any issues with the information it extracts for running Echidna. Again, if you are using a compilation framework, use the command:

slither . --print echidna

If that command executes correctly, it should print a JSON file containing some information from your contracts; otherwise, report any errors in the slither issue tracker. If everything here works, but Echidna still fails, please open an issue in our issue tracker or ask in the #ethereum channel of the EmpireHacking Slack.

How long should I run Echidna?

Echidna uses fuzzing testing, which runs for a fixed number of transactions (or a global timeout). Users should specify an appropriate number of transactions or a timeout (in seconds) depending on the available resources for a fuzzing campaign and the complexity of the code. Determining the best amount of time to run a fuzzer is still an open research question; however, monitoring the code coverage of your smart contracts can be a good way to determine if the fuzzing campaign should be extended.

Why has Echidna not implemented fuzzing of smart contract constructors with parameters?

Echidna is focused on security testing during audits. When we perform testing, we tailor the fuzzing campaign to test a limited number of possible constructor parameters (normally, the ones that will be used for the actual deployment). We do not focus on issues that depend on alternative deployments that should not occur. Moreover, redeploying contracts during the fuzzing campaign has a performance impact, and the sequences of transactions that we collect in the corpus may be more challenging (or even impossible) to reuse and mutate in different contexts.

How does Echidna determine which sequence of transactions should be added to the corpus?

Echidna begins by generating a sequence with a number of transactions to execute. It executes each transaction one by one, collecting coverage information for every transaction. If a transaction adds new coverage, then the entire sequence (up to that transaction) is added to the corpus.

How is coverage information used?

Coverage information is used to determine if a sequence of transactions has reached a new program state and is added to the internal corpus.

What exactly is coverage information?

Coverage is a combination of the following information:

  • Echidna reached a specific program counter in a given contract.
  • The execution ended, either with stop, revert, or a variety of errors (e.g., assertion failed, out of gas, insufficient ether balance, etc.)
  • The number of EVM frames when the execution ended (in other words, how deep the execution ends in terms of internal transactions)

How is the corpus used?

The corpus is used as the primary source of transactions to replay and mutate during a fuzzing campaign. The probability of using a sequence of transactions to replay and mutate is directly proportional to the number of transactions needed to add it to the corpus. In other words, rarer sequences are replayed and mutated more frequently during a fuzzing campaign.

When a new sequence of transactions is added to the corpus, does this always mean that a new line of code is reached?

Not always. It means we have reached a certain program state given our coverage definition.

Why not use coverage per individual transaction instead of per sequence of transactions?

Coverage per individual transaction is possible, but it provides an incomplete view of the coverage since some code requires previous transactions to reach specific lines.

How do I know which type of testing should be used (boolean properties, assertions, etc.)?

Refer to the tutorial on selecting the right test mode.

Why does Echidna return "Property X failed with no transactions made" when running one or more tests?

Before starting a fuzzing campaign, Echidna tests the properties without any transactions to check for failures. In that case, a property may fail in the initial state (after the contract is deployed). You should check that the property is correct to understand why it fails without any transactions.

How can I determine how a property or assertion failed?

Echidna indicates the cause of a failed test in the UI. For instance, if a boolean property X fails due to a revert, Echidna will display "Property X FAILED! with ErrorRevert"; otherwise, it should show "Property X FAILED! with ErrorReturnFalse". An assertion can only fail with "ErrorUnrecognizedOpcode," which is how Solidity implements assertions in the EVM.

How can I pinpoint where and how a property or assertion failed?

Events are an easy way to output values from the EVM. You can use them to obtain information in the code containing the failed property or assertion. Only the events of the transaction triggering the failure will be shown (this will be improved in the near future). Also, events are collected and displayed, even if the transaction reverted (despite the Yellow Paper stating that the event log should be cleared).

Another way to see where an assertion failed is by using the coverage information. This requires enabling corpus collection (e.g., --corpus-dir X) and checking the coverage in the coverage*.txt file, such as:

*e  |   function test(int x, address y, address z) public {
*e  |     require(x > 0 || x <= 0);
*e  |     assert(z != address(0x0));
*   |     assert(y != z);
*   |     state = x;
    |   }

The e marker indicates that Echidna collected a trace that ended with an assertion failure. As we can see, the path ends at the assert statement, so it should fail there.

Why does coverage information seem incorrect or incomplete?

Coverage mappings can be imprecise; however, if they fail completely, it could be that you are using the viaIR optimization option, which appears to have some unexpected impact on the solc maps that we are still investigating. As a workaround, disable viaIR.

Echidna crashes, displaying "NonEmpty.fromList: empty list"

Echidna relies on the Solidity metadata to detect where each contract is deployed. Please do not disable it. If this is not the case, please open an issue in our issue tracker.

Echidna stopped working for some reason. How can I debug it?

Use --format text and open an issue with the error you see in your console, or ask in the #ethereum channel at the EmpireHacking Slack.

I am not getting the expected results from Echidna tests. What can I do?

Sometimes, it is useful to create small properties or assertions to test whether the tool executed them correctly. For instance, for property mode:

function echidna_test() public returns (bool) {
    return false;
}

And for assertion mode:

function test_assert_false() public {
    assert(false);
}

If these do not fail, please open an issue so that we can investigate.

Exercises

Exercise 1

Table of Contents:

Join the team on Slack at: https://slack.empirehacking.nyc/ #ethereum

Targeted Contract

We will test the following contract token.sol:

pragma solidity ^0.8.0;

contract Ownable {
    address public owner = msg.sender;

    modifier onlyOwner() {
        require(msg.sender == owner, "Ownable: Caller is not the owner.");
        _;
    }
}

contract Pausable is Ownable {
    bool private _paused;

    function paused() public view returns (bool) {
        return _paused;
    }

    function pause() public onlyOwner {
        _paused = true;
    }

    function resume() public onlyOwner {
        _paused = false;
    }

    modifier whenNotPaused() {
        require(!_paused, "Pausable: Contract is paused.");
        _;
    }
}

contract Token is Ownable, Pausable {
    mapping(address => uint256) public balances;

    function transfer(address to, uint256 value) public whenNotPaused {
        // unchecked to save gas
        unchecked {
            balances[msg.sender] -= value;
            balances[to] += value;
        }
    }
}

Testing a Token Balance

Goals

  • Add a property to check that the address echidna cannot have more than an initial balance of 10,000.
  • After Echidna finds the bug, fix the issue, and re-check your property with Echidna.

The skeleton for this exercise is (template.sol):

pragma solidity ^0.8.0;

import "./token.sol";

/// @dev Run the template with
///      ```
///      solc-select use 0.8.0
///      echidna program-analysis/echidna/exercises/exercise1/template.sol
///      ```
contract TestToken is Token {
    address echidna = tx.origin;

    constructor() {
        balances[echidna] = 10_000;
    }

    function echidna_test_balance() public view returns (bool) {
        // TODO: add the property
    }
}

Solution

This solution can be found in solution.sol.

Exercise 2

This exercise requires completing exercise 1.

Table of contents:

Join the team on Slack at: https://slack.empirehacking.nyc/ #ethereum

Targeted contract

We will test the following contract, token.sol:

pragma solidity ^0.8.0;

contract Ownable {
    address public owner = msg.sender;

    function Owner() public {
        owner = msg.sender;
    }

    modifier onlyOwner() {
        require(owner == msg.sender);
        _;
    }
}

contract Pausable is Ownable {
    bool private _paused;

    function paused() public view returns (bool) {
        return _paused;
    }

    function pause() public onlyOwner {
        _paused = true;
    }

    function resume() public onlyOwner {
        _paused = false;
    }

    modifier whenNotPaused() {
        require(!_paused, "Pausable: Contract is paused.");
        _;
    }
}

contract Token is Ownable, Pausable {
    mapping(address => uint256) public balances;

    function transfer(address to, uint256 value) public whenNotPaused {
        balances[msg.sender] -= value;
        balances[to] += value;
    }
}

Testing access control

Goals

  • Assume pause() is called at deployment, and the ownership is removed.
  • Add a property to check that the contract cannot be unpaused.
  • When Echidna finds the bug, fix the issue and retry your property with Echidna.

The skeleton for this exercise is (template.sol):

pragma solidity ^0.8.0;

import "./token.sol";

/// @dev Run the template with
///      ```
///      solc-select use 0.8.0
///      echidna program-analysis/echidna/exercises/exercise2/template.sol
///      ```
contract TestToken is Token {
    constructor() {
        pause(); // pause the contract
        owner = address(0); // lose ownership
    }

    function echidna_cannot_be_unpause() public view returns (bool) {
        // TODO: add the property
    }
}

Solution

The solution can be found in solution.sol.

Exercise 3

This exercise requires completing exercise 1 and exercise 2.

Table of contents:

Join the team on Slack at: https://slack.empirehacking.nyc/ #ethereum

Targeted contract

We will test the following contract token.sol:

pragma solidity ^0.8.0;

/// @notice The issues from exercise 1 and 2 are fixed.

contract Ownable {
    address public owner = msg.sender;

    modifier onlyOwner() {
        require(msg.sender == owner, "Ownable: Caller is not the owner.");
        _;
    }
}

contract Pausable is Ownable {
    bool private _paused;

    function paused() public view returns (bool) {
        return _paused;
    }

    function pause() public onlyOwner {
        _paused = true;
    }

    function resume() public onlyOwner {
        _paused = false;
    }

    modifier whenNotPaused() {
        require(!_paused, "Pausable: Contract is paused.");
        _;
    }
}

contract Token is Ownable, Pausable {
    mapping(address => uint256) public balances;

    function transfer(address to, uint256 value) public whenNotPaused {
        balances[msg.sender] -= value;
        balances[to] += value;
    }
}

Testing with custom initialization

Consider the following extension of the token (mintable.sol):

pragma solidity ^0.8.0;

import "./token.sol";

contract MintableToken is Token {
    int256 public totalMinted;
    int256 public totalMintable;

    constructor(int256 totalMintable_) {
        totalMintable = totalMintable_;
    }

    function mint(uint256 value) public onlyOwner {
        require(int256(value) + totalMinted < totalMintable);
        totalMinted += int256(value);

        balances[msg.sender] += value;
    }
}

The version of token.sol contains the fixes from the previous exercises.

Goals

  • Create a scenario where echidna (tx.origin) becomes the owner of the contract at construction, and totalMintable is set to 10,000. Remember that Echidna needs a constructor without arguments.
  • Add a property to check if echidna can mint more than 10,000 tokens.
  • Once Echidna finds the bug, fix the issue, and re-try your property with Echidna.

The skeleton for this exercise is template.sol:

pragma solidity ^0.8.0;

import "./mintable.sol";

/// @dev Run the template with
///      ```
///      solc-select use 0.8.0
///      echidna program-analysis/echidna/exercises/exercise3/template.sol --contract TestToken
///      ```
contract TestToken is MintableToken {
    address echidna = msg.sender;

    // TODO: update the constructor
    constructor(int256 totalMintable) MintableToken(totalMintable) {}

    function echidna_test_balance() public view returns (bool) {
        // TODO: add the property
    }
}

Solution

This solution can be found in solution.sol.

Exercise 4

Table of contents:

This exercise is based on the tutorial How to test assertions.

Join the team on Slack at: https://slack.empirehacking.nyc/ #ethereum

Targeted contract

We will test the following contract, token.sol:

pragma solidity ^0.8.0;

contract Ownable {
    address public owner = msg.sender;

    function transferOwnership(address newOwner) public onlyOwner {
        owner = newOwner;
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "Ownable: Caller is not the owner.");
        _;
    }
}

contract Pausable is Ownable {
    bool private _paused;

    function paused() public view returns (bool) {
        return _paused;
    }

    function pause() public onlyOwner {
        _paused = true;
    }

    function resume() public onlyOwner {
        _paused = false;
    }

    modifier whenNotPaused() {
        require(!_paused, "Pausable: Contract is paused.");
        _;
    }
}

contract Token is Ownable, Pausable {
    mapping(address => uint256) public balances;

    function transfer(address to, uint256 value) public virtual whenNotPaused {
        // unchecked to save gas
        unchecked {
            balances[msg.sender] -= value;
            balances[to] += value;
        }
    }
}

Exercise

Goals

Add assertions to ensure that after calling transfer:

  • msg.sender must have its initial balance or less.
  • to must have its initial balance or more.

Once Echidna finds the bug, fix the issue, and re-try your assertion with Echidna.

This exercise is similar to the first one, but it uses assertions instead of explicit properties.

The skeleton for this exercise is (template.sol):

pragma solidity ^0.8.0;

import "./token.sol";

/// @dev Run the template with
///      ```
///      solc-select use 0.8.0
///      echidna program-analysis/echidna/exercises/exercise4/template.sol --contract TestToken --test-mode assertion
///      ```
///      or by providing a config
///      ```
///      echidna program-analysis/echidna/exercises/exercise4/template.sol --contract TestToken --config program-analysis/echidna/exercises/exercise4/config.yaml
///      ```
contract TestToken is Token {
    function transfer(address to, uint256 value) public {
        // TODO: include `assert(condition)` statements that
        // detect a breaking invariant on a transfer.
        // Hint: you may use the following to wrap the original function.
        super.transfer(to, value);
    }
}

Solution

This solution can be found in solution.sol

Exercise 5

Table of contents:

Join the team on Slack at: https://slack.empirehacking.nyc/ #ethereum

Setup

  1. Clone the repo: git clone https://github.com/crytic/damn-vulnerable-defi-echidna
  2. Install the dependencies by running yarn install.

Context

The challenge is described here: https://www.damnvulnerabledefi.xyz/challenges/2.html. It is assumed that the reader is familiar with the challenge.

Goals

  • Set up the testing environment with the correct contracts and necessary balances.
  • Analyze the "before" function in test/naive-receiver/naive-receiver.challenge.js to identify the required initial setup.
  • Add a property to check if the balance of the FlashLoanReceiver contract can change.
  • Create a config.yaml with the necessary configuration option(s).
  • Once Echidna finds the bug, fix the issue and re-test your property with Echidna.

The following contracts are relevant:

  • contracts/naive-receiver/FlashLoanReceiver.sol
  • contracts/naive-receiver/NaiveReceiverLenderPool.sol

Hints

It is recommended to first attempt without reading the hints. The hints can be found in the hints branch.

Solution

The solution can be found in the solutions branch.

Solution Explained (spoilers ahead)

The goal of the naive receiver challenge is to realize that any user can request a flash loan for FlashLoanReceiver, even if the user has no Ether.

Echidna discovers this by calling NaiveReceiverLenderPool.flashLoan() with the address of FlashLoanReceiver and any arbitrary amount.

See the example output from Echidna below:

echidna . --contract NaiveReceiverEchidna --config naivereceiver.yaml
...

echidna_test_contract_balance: failed!💥
  Call sequence:
    flashLoan(0x62d69f6867a0a084c6d313943dc22023bc263691,353073667)

...

Exercise 6

Table of Contents:

Join the team on Slack at: https://slack.empirehacking.nyc/ #ethereum

Setup

  1. Clone the repository: git clone https://github.com/crytic/damn-vulnerable-defi-echidna
  2. Install the dependencies with yarn install.

Context

The challenge is described here: https://www.damnvulnerabledefi.xyz/challenges/1.html. We assume that the reader is familiar with it.

Goals

  • Set up the testing environment with the appropriate contracts and necessary balances.
  • Analyze the "before" function in test/unstoppable/unstoppable.challenge.js to identify the initial setup required.
  • Add a property to check whether UnstoppableLender can always provide flash loans.
  • Create a config.yaml file with the required configuration option(s).
  • Once Echidna finds the bug, fix the issue, and retry your property with Echidna.

Only the following contracts are relevant:

  • contracts/DamnValuableToken.sol
  • contracts/unstoppable/UnstoppableLender.sol
  • contracts/unstoppable/ReceiverUnstoppable.sol

Hints

We recommend trying without reading the following hints first. The hints are in the hints branch.

Solution

This solution can be found in the solutions branch.

Solution Explained (spoilers ahead)

Note: Please ensure that you have placed solution.sol (or UnstoppableEchidna.sol) in contracts/unstoppable.

The goal of the unstoppable challenge is to recognize that UnstoppableLender has two modes of tracking its balance: poolBalance and damnValuableToken.balanceOf(address(this)).

poolBalance is increased when someone calls depositTokens().

However, a user can call damnValuableToken.transfer() directly and increase the balanceOf(address(this)) without increasing poolBalance.

Now, the two balance trackers are out of sync.

When Echidna calls pool.flashLoan(10), the assertion assert(poolBalance == balanceBefore) in UnstoppableLender will fail, and the pool can no longer provide flash loans.

See the example output below from Echidna:

echidna . --contract UnstoppableEchidna --config unstoppable.yaml

...

echidna_testFlashLoan: failed!💥
  Call sequence:
    transfer(0x62d69f6867a0a084c6d313943dc22023bc263691,1296000)

...

Exercise 7

Table of contents:

Join the team on Slack at: https://slack.empirehacking.nyc/ #ethereum

Setup

  1. Clone the repository: git clone https://github.com/crytic/damn-vulnerable-defi-echidna
  2. Install dependencies using yarn install.
  3. Analyze the before function in test/side-entrance/side-entrance.challenge.js to determine the initial setup requirements.
  4. Create a contract to be used for property testing with Echidna.

No skeleton will be provided for this exercise.

Goals

  • Set up the testing environment with appropriate contracts and necessary balances.
  • Add a property to check if the balance of the SideEntranceLenderPool contract has changed.
  • Create a config.yaml with the required configuration option(s).
  • After Echidna discovers the bug, fix the issue and test your property with Echidna again.

Hint: To become familiar with the workings of the target contract, try manually executing a flash loan.

Solution

The solution can be found in solution.sol.

Solution Explained (spoilers ahead)

The goal of the side entrance challenge is to realize that the contract's ETH balance accounting is misconfigured. The balanceBefore variable tracks the contract's balance before the flash loan, while address(this).balance tracks the balance after the flash loan. As a result, you can use the deposit function to repay your flash loan while maintaining the notion that the contract's total ETH balance hasn't changed (i.e., address(this).balance >= balanceBefore). However, since you now own the deposited ETH, you can also withdraw it and drain all the funds from the contract.

For Echidna to interact with the SideEntranceLenderPool, it must be deployed first. Deploying and funding the pool from the Echidna property testing contract won't work, as the funding transaction's msg.sender will be the contract itself. This means that the Echidna contract will own the funds, allowing it to remove them by calling withdraw() without exploiting the vulnerability.

To avoid the above issue, create a simple factory contract that deploys the pool without setting the Echidna property testing contract as the owner of the funds. This factory will have a public function that deploys a SideEntranceLenderPool, funds it with the given amount, and returns its address. Since the Echidna testing contract does not own the funds, it cannot call withdraw() to empty the pool.

With the challenge properly set up, instruct Echidna to execute a flash loan. By using the setEnableWithdraw and setEnableDeposit, Echidna will search for functions to call within the flash loan callback to attempt to break the testPoolBalance property.

Echidna will eventually discover that if (1) deposit is used to repay the flash loan and (2) withdraw is called immediately afterward, the testPoolBalance property fails.

Example Echidna output:

echidna . --contract EchidnaSideEntranceLenderPool --config config.yaml
...
testPoolBalance(): failed!💥
  Call sequence:
    execute() Value: 0x103
    setEnableDeposit(true,256)
    flashLoan(1)
    setEnableWithdraw(true)
    setEnableDeposit(false,0)
    execute()
    testPoolBalance()
...

Exercise 8

Table of Contents:

Join the team on Slack at: https://slack.empirehacking.nyc/ #ethereum

Setup

  1. Clone the repo: git clone https://github.com/crytic/damn-vulnerable-defi-echidna.
  2. Install the dependencies via yarn install.

Context

The challenge is described here: https://www.damnvulnerabledefi.xyz/challenges/5.html. We assume that the reader is familiar with it.

Goals

  • Set up the testing environment with the right contracts and necessary balances.
  • Analyze the before function in test/the-rewarder/the-rewarder.challenge.js to identify what initial setup needs to be done.
  • Add a property to check whether the attacker can get almost the entire reward (let us say more than 99 %) from the TheRewarderPool contract.
  • Create a config.yaml with the necessary configuration option(s).
  • Once Echidna finds the bug, you will need to apply a completely different reward logic to fix the issue, as the current solution is a rather naive implementation.

Only the following contracts are relevant:

  • contracts/the-rewarder/TheRewarderPool.sol
  • contracts/the-rewarder/FlashLoanerPool.sol

Hints

We recommend trying without reading the following hints first. The hints are in the hints branch.

  • The invariant you are looking for is "an attacker cannot get almost the entire amount of rewards."
  • Read about the allContracts option.
  • A config file is provided in the-rewarder.yaml.

Solution

This solution can be found in the solutions branch.

Solution Explained (spoilers ahead)

The goal of the rewarder challenge is to realize that an arbitrary user can request a flash loan from the FlashLoanerPool and borrow the entire amount of Damn Valuable Tokens (DVT) available. Next, this amount of DVT can be deposited into TheRewarderPool. By doing this, the user affects the total proportion of tokens deposited in TheRewarderPool (and thus gets most of the percentage of deposited assets in that particular time on their side). Furthermore, if the user schedules this at the right time (once REWARDS_ROUND_MIN_DURATION is reached), a snapshot of users' deposits is taken. The user then immediately repays the loan (i.e., in the same transaction) and receives almost the entire reward in return. In fact, this can be done even if the arbitrary user has no DVT.

Echidna reveals this vulnerability by finding the right order of two functions: simply calling (1) TheRewarderPool.deposit() (with prior approval) and (2) TheRewarderPool.withdraw() with the max amount of DVT borrowed through the flash loan in both mentioned functions.

See the example output below from Echidna:

echidna . --contract EchidnaRewarder --config ./the-rewarder.yaml
...

testRewards(): failed!💥
  Call sequence:
    *wait* Time delay: 441523 seconds Block delay: 9454
    setEnableDeposit(true) from: 0x0000000000000000000000000000000000030000
    setEnableWithdrawal(true) from: 0x0000000000000000000000000000000000030000
    flashLoan(39652220640884191256808) from: 0x0000000000000000000000000000000000030000
    testRewards() from: 0x0000000000000000000000000000000000030000

...

Manticore Tutorial

The aim of this tutorial is to show how to use Manticore to automatically find bugs in smart contracts.

The first part introduces a set of the basic features of Manticore: running under Manticore and manipulating smart contracts through API, getting throwing path, adding constraints. The second part is exercise to solve.

Table of contents:

Join the team on Slack at: https://slack.empirehacking.nyc/ #ethereum, #manticore

Installation

Manticore requires >= python 3.6. It can be installed through pip or using docker.

Manticore through docker

docker pull trailofbits/eth-security-toolbox
docker run -it -v "$PWD":/home/training trailofbits/eth-security-toolbox

The last command runs eth-security-toolbox in a docker that has access to your current directory. You can change the files from your host, and run the tools on the files from the docker

Inside docker, run:

solc-select 0.5.11
cd /home/trufflecon/

Manticore through pip

pip3 install --user manticore

solc 0.5.11 is recommended.

Running a script

To run a python script with python 3:

python3 script.py

Introduction to dynamic symbolic execution

Manticore is a dynamic symbolic execution tool, we described in our previous blogposts (1, 2,3).

Dynamic Symbolic Execution in a Nutshell

Dynamic symbolic execution (DSE) is a program analysis technique that explores a state space with a high degree of semantic awareness. This technique is based on the discovery of "program paths", represented as mathematical formulas called path predicates. Conceptually, this technique operates on path predicates in two steps:

  1. They are constructed using constraints on the program's input.
  2. They are used to generate program inputs that will cause the associated paths to execute.

This approach produces no false positives in the sense that all identified program states can be triggered during concrete execution. For example, if the analysis finds an integer overflow, it is guaranteed to be reproducible.

Path Predicate Example

To get an insigh of how DSE works, consider the following example:

function f(uint256 a) {
    if (a == 65) {
        // A bug is present
    }
}

As f() contains two paths, a DSE will construct two differents path predicates:

  • Path 1: a == 65
  • Path 2: Not (a == 65)

Each path predicate is a mathematical formula that can be given to a so-called SMT solver, which will try to solve the equation. For Path 1, the solver will say that the path can be explored with a = 65. For Path 2, the solver can give a any value other than 65, for example a = 0.

Verifying properties

Manticore allows a full control over all the execution of each path. As a result, it allows to add arbirtray contraints to almost anything. This control allows for the creation of properties on the contract.

Consider the following example:

function unsafe_add(uint256 a, uint256 b) returns (uint256 c) {
    c = a + b; // no overflow protection
    return c;
}

Here there is only one path to explore in the function:

  • Path 1: c = a + b

Using Manticore, you can check for overflow, and add constraitns to the path predicate:

  • c = a + b AND (c < a OR c < b)

If it is possible to find a valuation of a and b for which the path predicate above is feasible, it means that you have found an overflow. For example the solver can generate the input a = 10 , b = MAXUINT256.

If you consider a fixed version:

function safe_add(uint256 a, uint256 b) returns (uint256 c) {
    c = a + b;
    require(c >= a);
    require(c >= b);
    return c;
}

The associated formula with overflow check would be:

  • c = a + b AND (c >= a) AND (c=>b) AND (c < a OR c < b)

This formula cannot be solved; in other words this is a proof that in safe_add, c will always increase.

DSE is thereby a powerful tool, that can verify arbitrary constraints on your code.

Running under Manticore

Table of contents:

Introduction

We will see how to explore a smart contract with the Manticore API. The target is the following smart contract (example.sol):

pragma solidity >=0.4.24 <0.6.0;

contract Simple {
    function f(uint256 a) public payable {
        if (a == 65) {
            revert();
        }
    }
}

Run a standalone exploration

You can run Manticore directly on the smart contract by the following command (project can be a Solidity File, or a project directory):

manticore project

You will get the output of testcases like this one (the order may change):

...
... m.c.manticore:INFO: Generated testcase No. 0 - STOP
... m.c.manticore:INFO: Generated testcase No. 1 - REVERT
... m.c.manticore:INFO: Generated testcase No. 2 - RETURN
... m.c.manticore:INFO: Generated testcase No. 3 - REVERT
... m.c.manticore:INFO: Generated testcase No. 4 - STOP
... m.c.manticore:INFO: Generated testcase No. 5 - REVERT
... m.c.manticore:INFO: Generated testcase No. 6 - REVERT
... m.c.manticore:INFO: Results in /home/ethsec/workshops/Automated Smart Contracts Audit - TruffleCon 2018/manticore/examples/mcore_t6vi6ij3
...

Without additional information, Manticore will explore the contract with new symbolic transactions until it does not explore new paths on the contract. Manticore does not run new transactions after a failing one (e.g: after a revert).

Manticore will output the information in a mcore_* directory. Among other, you will find in this directory:

  • global.summary: coverage and compiler warnings
  • test_XXXXX.summary: coverage, last instruction, account balances per test case
  • test_XXXXX.tx: detailed list of transactions per test case

Here Manticore founds 7 test cases, which correspond to (the filename order may change):

Transaction 0Transaction 1Transaction 2Result
test_00000000.txContract creationf(!=65)f(!=65)STOP
test_00000001.txContract creationfallback functionREVERT
test_00000002.txContract creationRETURN
test_00000003.txContract creationf(65)REVERT
test_00000004.txContract creationf(!=65)STOP
test_00000005.txContract creationf(!=65)f(65)REVERT
test_00000006.txContract creationf(!=65)fallback functionREVERT

Exploration summary f(!=65) denotes f called with any value different than 65.

As you can notice, Manticore generates an unique test case for every successful or reverted transaction.

Use the --quick-mode flag if you want fast code exploration (it disable bug detectors, gas computation, ...)

Manipulate a smart contract through the API

This section describes details how to manipulate a smart contract through the Manticore Python API. You can create new file with python extension *.py and write the necessary code by adding the API commands (basics of which will be described below) into this file and then run it with the command $ python3 *.py. Also you can execute the commands below directly into the python console, to run the console use the command $ python3.

Creating Accounts

The first thing you should do is to initiate a new blockchain with the following commands:

from manticore.ethereum import ManticoreEVM

m = ManticoreEVM()

A non-contract account is created using m.create_account:

user_account = m.create_account(balance=1 * 10**18)

A Solidity contract can be deployed using m.solidity_create_contract:

source_code = '''
pragma solidity >=0.4.24 <0.6.0;
contract Simple {
    function f(uint256 a) payable public {
        if (a == 65) {
            revert();
        }
    }
}
'''
# Initiate the contract
contract_account = m.solidity_create_contract(source_code, owner=user_account)

Summary

Executing transactions

Manticore supports two types of transaction:

  • Raw transaction: all the functions are explored
  • Named transaction: only one function is explored

Raw transaction

A raw transaction is executed using m.transaction:

m.transaction(caller=user_account,
              address=contract_account,
              data=data,
              value=value)

The caller, the address, the data, or the value of the transaction can be either concrete or symbolic:

For example:

symbolic_value = m.make_symbolic_value()
symbolic_data = m.make_symbolic_buffer(320)
m.transaction(caller=user_account,
              address=contract_address,
              data=symbolic_data,
              value=symbolic_value)

If the data is symbolic, Manticore will explore all the functions of the contract during the transaction execution. It will be helpful to see the Fallback Function explanation in the Hands on the Ethernaut CTF article for understanding how the function selection works.

Named transaction

Functions can be executed through their name. To execute f(uint256 var) with a symbolic value, from user_account, and with 0 ether, use:

symbolic_var = m.make_symbolic_value()
contract_account.f(symbolic_var, caller=user_account, value=0)

If value of the transaction is not specified, it is 0 by default.

Summary

  • Arguments of a transaction can be concrete or symbolic
  • A raw transaction will explore all the functions
  • Function can be called by their name

Workspace

m.workspace is the directory used as output directory for all the files generated:

print("Results are in {}".format(m.workspace))

Terminate the Exploration

To stop the exploration use m.finalize(). No further transactions should be sent once this method is called and Manticore generates test cases for each of the path explored.

Summary: Running under Manticore

Putting all the previous steps together, we obtain:

from manticore.ethereum import ManticoreEVM

m = ManticoreEVM()

with open('example.sol') as f:
    source_code = f.read()

user_account = m.create_account(balance=1*10**18)
contract_account = m.solidity_create_contract(source_code, owner=user_account)

symbolic_var = m.make_symbolic_value()
contract_account.f(symbolic_var)

print("Results are in {}".format(m.workspace))
m.finalize() # stop the exploration

All the code above you can find into the example_run.py

The next step is to accessing the paths.

Getting Throwing Path

Table of contents:

Introduction

We will now improve the previous example and generate specific inputs for the paths raising an exception in f(). The target is still the following smart contract (example.sol):

pragma solidity >=0.4.24 <0.6.0;

contract Simple {
    function f(uint256 a) public payable {
        if (a == 65) {
            revert();
        }
    }
}

Using state information

Each path executed has its state of the blockchain. A state is either ready or it is killed, meaning that it reaches a THROW or REVERT instruction:

  • m.ready_states: the list of states that are ready (they did not execute a REVERT/INVALID)
  • m.killed_states: the list of states that are ready (they did not execute a REVERT/INVALID)
  • m.all_states: all the states
for state in m.all_states:
    # do something with state

You can access state information. For example:

  • state.platform.get_balance(account.address): the balance of the account
  • state.platform.transactions: the list of transactions
  • state.platform.transactions[-1].return_data: the data returned by the last transaction

The data returned by the last transaction is an array, which can be converted to a value with ABI.deserialize, for example:

data = state.platform.transactions[0].return_data
data = ABI.deserialize("uint256", data)

How to generate testcase

Use m.generate_testcase(state, name) to generate testcase:

m.generate_testcase(state, 'BugFound')

Summary

  • You can iterate over the state with m.all_states
  • state.platform.get_balance(account.address) returns the account’s balance
  • state.platform.transactions returns the list of transactions
  • transaction.return_data is the data returned
  • m.generate_testcase(state, name) generate inputs for the state

Summary: Getting Throwing Path

from manticore.ethereum import ManticoreEVM

m = ManticoreEVM()

with open('example.sol') as f:
    source_code = f.read()

user_account = m.create_account(balance=1*10**18)
contract_account = m.solidity_create_contract(source_code, owner=user_account)

symbolic_var = m.make_symbolic_value()
contract_account.f(symbolic_var)

## Check if an execution ends with a REVERT or INVALID
for state in m.terminated_states:
    last_tx = state.platform.transactions[-1]
    if last_tx.result in ['REVERT', 'INVALID']:
        print('Throw found {}'.format(m.workspace))
        m.generate_testcase(state, 'ThrowFound')

All the code above you can find into the example_throw.py

The next step is to add constraints to the state.

Note we could have generated a much simpler script, as all the states returned by terminated_state have REVERT or INVALID in their result: this example was only meant to demonstrate how to manipulate the API.

Adding Constraints

Table of contents:

Introduction

We will see how to constrain the exploration. We will make the assumption that the documentation of f() states that the function is never called with a == 65, so any bug with a == 65 is not a real bug. The target is still the following smart contract (example.sol):

pragma solidity >=0.4.24 <0.6.0;

contract Simple {
    function f(uint256 a) public payable {
        if (a == 65) {
            revert();
        }
    }
}

Operators

The Operators module facilitates the manipulation of constraints, among other it provides:

  • Operators.AND,
  • Operators.OR,
  • Operators.UGT (unsigned greater than),
  • Operators.UGE (unsigned greater than or equal to),
  • Operators.ULT (unsigned lower than),
  • Operators.ULE (unsigned lower than or equal to).

To import the module use the following:

from manticore.core.smtlib import Operators

Operators.CONCAT is used to concatenate an array to a value. For example, the return_data of a transaction needs to be changed to a value to be checked against another value:

last_return = Operators.CONCAT(256, *last_return)

Constraints

You can use constraints globally or for a specific state.

Global constraint

Use m.constrain(constraint) to add a global cosntraint. For example, you can call a contract from a symbolic address, and restraint this address to be specific values:

symbolic_address = m.make_symbolic_value()
m.constraint(Operators.OR(symbolic == 0x41, symbolic_address == 0x42))
m.transaction(caller=user_account,
              address=contract_account,
              data=m.make_symbolic_buffer(320),
              value=0)

State constraint

Use state.constrain(constraint) to add a constraint to a specific state It can be used to constrain the state after its exploration to check some property on it.

Checking Constraint

Use solver.check(state.constraints) to know if a constraint is still feasible. For example, the following will constraint symbolic_value to be different from 65 and check if the state is still feasible:

state.constrain(symbolic_var != 65)
if solver.check(state.constraints):
    # state is feasible

Summary: Adding Constraints

Adding constraint to the previous code, we obtain:

from manticore.ethereum import ManticoreEVM
from manticore.core.smtlib.solver import Z3Solver

solver = Z3Solver.instance()

m = ManticoreEVM()

with open("example.sol") as f:
    source_code = f.read()

user_account = m.create_account(balance=1*10**18)
contract_account = m.solidity_create_contract(source_code, owner=user_account)

symbolic_var = m.make_symbolic_value()
contract_account.f(symbolic_var)

no_bug_found = True

## Check if an execution ends with a REVERT or INVALID
for state in m.terminated_states:
    last_tx = state.platform.transactions[-1]
    if last_tx.result in ['REVERT', 'INVALID']:
        # we do not consider the path were a == 65
        condition = symbolic_var != 65
        if m.generate_testcase(state, name="BugFound", only_if=condition):
            print(f'Bug found, results are in {m.workspace}')
            no_bug_found = False

if no_bug_found:
    print(f'No bug found')

All the code above you can find into the example_constraint.py

The next step is to follow the exercises.

Manticore Exercises

Example: Arithmetic overflow

This scenario is given as an example. You can follow its structure to solve the exercises.

my_token.py uses Manticore to find for an attacker to generate tokens during a transfer on Token (my_token.sol).

Proposed scenario

We use the pattern initialization, exploration and property for our scripts.

Initialization

  • Create one user account
  • Create the contract account

Exploration

  • Call balances on the user account
  • Call transfer with symbolic destination and value
  • Call balances on the user account

Property

  • Check if the user can have more token after the transfer than before.

Exercise 1 : Arithmetic rounding

Use Manticore to find an input allowing an attacker to generate free tokens in token.sol. Propose a fix of the contract, and test your fix using your Manticore script.

Proposed scenario

Follow the pattern initialization, exploration and property for the script.

Initialization

  • Create one account
  • Create the contract account

Exploration

  • Call is_valid_buy with two symbolic values for tokens_amount and wei_amount

Property

  • An attack is found if on a state alive wei_amount == 0 and tokens_amount >= 1

Hints

  • m.ready_states returns the list of state alive
  • Operators.AND(a, b) allows to create and AND condition
  • You can use the template proposed in template.py

Solution

solution.py

Exercise 2 : Arithmetic overflow through multiple transactions

Use Manticore to find if an overflow is possible in Overflow.add. Propose a fix of the contract, and test your fix using your Manticore script.

Proposed scenario

Follow the pattern initialization, exploration and property for the script.

Initialization

  • Create one user account
  • Create the contract account

Exploration

  • Call two times add with two symbolic values
  • Call sellerBalance()

Property

  • Check if it is possible for the value returned by sellerBalance() to be lower than the first input.

Hints

  • The value returned by the last transaction can be accessed through:
state.platform.transactions[-1].return_data
  • The data returned needs to be deserialized:
data = ABI.deserialize("uint256", data)

Solution

solution.py.

Slither

The objective of this tutorial is to demonstrate how to use Slither to automatically find bugs in smart contracts.

Once you feel confident with the material in this README, proceed to the exercises:

Watch Slither's code walkthrough, or API walkthrough to learn about its code structure.

Installation

Slither requires Python >= 3.8. You can install it through pip or by using Docker.

Installing Slither through pip:

pip3 install --user slither-analyzer

Docker

Installing Slither through Docker:

docker pull trailofbits/eth-security-toolbox
docker run -it -v "$PWD":/home/trufflecon trailofbits/eth-security-toolbox

The last command runs the eth-security-toolbox in a Docker container that has access to your current directory. You can modify the files from your host, and run the tools on the files from the Docker container.

Inside the Docker container, run:

solc-select 0.5.11
cd /home/trufflecon/

Command Line

Command line vs. user-defined scripts. Slither comes with a set of pre-defined detectors that can identify many common bugs. Running Slither from the command line will execute all the detectors without requiring detailed knowledge of static analysis:

slither project_paths

Besides detectors, Slither also offers code review capabilities through its printers and tools.

Static analysis

The capabilities and design of the Slither static analysis framework have been described in blog posts (1, 2) and an academic paper.

Static analysis comes in different flavors. You may already know that compilers like clang and gcc rely on these research techniques, as do tools like Infer, CodeClimate, FindBugs, and tools based on formal methods like Frama-C and Polyspace.

In this article, we will not provide an exhaustive review of static analysis techniques and research. Instead, we'll focus on what you need to understand about how Slither works, so you can more effectively use it to find bugs and understand code.

Code representation

Unlike dynamic analysis, which reasons about a single execution path, static analysis reasons about all paths at once. To do so, it relies on a different code representation. The two most common ones are the abstract syntax tree (AST) and the control flow graph (CFG).

Abstract Syntax Trees (AST)

ASTs are used every time a compiler parses code. They are arguably the most basic structure upon which static analysis can be performed.

In a nutshell, an AST is a structured tree where, usually, each leaf contains a variable or a constant, and internal nodes are operands or control flow operations. Consider the following code:

function safeAdd(uint256 a, uint256 b) internal pure returns (uint256) {
    if (a + b <= a) {
        revert();
    }
    return a + b;
}

The corresponding AST is shown in the following illustration:

AST

Slither uses the AST exported by solc.

While simple to build, the AST is a nested structure that's not always straightforward to analyze. For example, to identify the operations used by the expression a + b <= a, you must first analyze <= and then +. A common approach is to use the so-called visitor pattern, which navigates through the tree recursively. Slither contains a generic visitor in ExpressionVisitor.

The following code uses ExpressionVisitor to detect if an expression contains an addition:

from slither.visitors.expression.expression import ExpressionVisitor
from slither.core.expressions.binary_operation import BinaryOperationType

class HasAddition(ExpressionVisitor):

    def result(self):
        return self._result

    def _post_binary_operation(self, expression):
        if expression.type == BinaryOperationType.ADDITION:
            self._result = True

visitor = HasAddition(expression) # expression is the expression to be tested
print(f'The expression {expression} has an addition: {visitor.result()}')

Control Flow Graph (CFG)

The second most common code representation is the control flow graph (CFG). As its name suggests, it is a graph-based representation that reveals all the execution paths. Each node contains one or multiple instructions, and edges in the graph represent control flow operations (if/then/else, loop, etc). The CFG of our previous example is as follows:

CFG

Most analyses are built on top of the CFG representation.

There are many other code representations, each with its advantages and drawbacks depending on the desired analysis.

Analysis

The simplest types of analyses that can be performed with Slither are syntactic analyses.

Syntax analysis

Slither can navigate through the different components of the code and their representation to find inconsistencies and flaws using a pattern matching-like approach.

For example, the following detectors look for syntax-related issues:

Semantic analysis

In contrast to syntax analysis, semantic analysis delves deeper and analyzes the "meaning" of the code. This category includes a broad range of analyses that yield more powerful and useful results but are more complex to write.

Semantic analyses are used for advanced vulnerability detection.

Data dependency analysis

A variable variable_a is said to be data-dependent on variable_b if there is a path for which the value of variable_a is influenced by variable_b.

In the following code, variable_a is dependent on variable_b:

// ...
variable_a = variable_b + 1;

Slither comes with built-in data dependency capabilities, thanks to its intermediate representation (discussed later).

An example of data dependency usage can be found in the dangerous strict equality detector. Slither looks for strict equality comparisons to dangerous values (incorrect_strict_equality.py#L86-L87) and informs the user that they should use >= or <= instead of == to prevent attackers from trapping the contract. Among other things, the detector considers the return value of a call to balanceOf(address) to be dangerous (incorrect_strict_equality.py#L63-L64) and uses the data dependency engine to track its usage.

Fixed-point computation

If your analysis navigates through the CFG and follows the edges, you're likely to encounter already visited nodes. For example, if a loop is presented as shown below:

for(uint256 i; i < range; ++) {
    variable_a += 1;
}

Your analysis will need to know when to stop. There are two main strategies: (1) iterate on each node a finite number of times, (2) compute a so-called fixpoint. A fixpoint essentially means that analyzing the node doesn't provide any meaningful information.

An example of a fixpoint used can be found in the reentrancy detectors: Slither explores the nodes and looks for external calls, reads, and writes to storage. Once it has reached a fixpoint (reentrancy.py#L125-L131), it stops the exploration and analyzes the results to see if a reentrancy is present, through different reentrancy patterns (reentrancy_benign.py, reentrancy_read_before_write.py, reentrancy_eth.py).

Writing analyses using efficient fixed-point computation requires a good understanding of how the analysis propagates its information.

Intermediate representation

An intermediate representation (IR) is a language designed to be more amenable to static analysis than the original one. Slither translates Solidity to its own IR: SlithIR.

Understanding SlithIR is not necessary if you only want to write basic checks. However, it becomes essential if you plan to write advanced semantic analyses. The SlithIR and SSA printers can help you understand how the code is translated.

API Basics

Slither has an API that allows you to explore basic attributes of contracts and their functions.

On a high level there are 6 layers:

  • Slither - main slither object
  • SlitherCompilationUnit - group of files used by one call to solc
  • Contract - contract level
  • Function - function level
  • Node - control flow graph
  • SlithrIR - intermediate representation

Watch our API walkthrough for more details

Slither object

To load a codebase:

from slither import Slither
slither = Slither('/path/to/project')

To load a contract deployed:

from slither import Slither
slither = Slither('0x..') # assuming the code is verified on etherscan

Use etherscan_api_key to provide an Etherscan API KEY

slither = Slither('0x..', etherscan_api_key='..')

You can retrieve the list of compilation units with:

  • sl.compilation_units # array of SlitherCompilationUnit

SlitherCompilationUnit object

  • ~ group of files used by one call to solc
  • Most targets have 1 compilation, but not always true
    • Partial compilation for optimization
    • Multiple solc version used
    • Etc..
  • Why compilation unit matters?
    • Some APIs might be not intuitive
    • Ex: looking for a contract based on the name?
      • Can have multiple contracts
  • For hacking you can (probably) use the first compilation unit
    • compilation_unit = sl.compilation_units[0]

A SlitherCompilationUnit has:

  • contracts (list(Contract)): A list of contracts
  • contracts_derived (list(Contract)): A list of contracts that are not inherited by another contract (a subset of contracts)
  • get_contract_from_name (str): Returns a list of contracts matching the name
  • [structures | enums | events | variables | functions]_top_level: Top level object

Example

from slither import Slither
sl = Slither("0xdac17f958d2ee523a2206206994597c13d831ec7")
compilation_unit = sl.compilation_units[0]

# Print all the contracts from the USDT address
print([str(c) for c in compilation_unit.contracts])

# Print the most derived contracts from the USDT address
print([str(c) for c in compilation_unit.contracts_derived])
% python test.py
['SafeMath', 'Ownable', 'ERC20Basic', 'ERC20', 'BasicToken', 'StandardToken', 'Pausable', 'BlackList', 'UpgradedStandardToken', 'TetherToken']

['SafeMath', 'UpgradedStandardToken', 'TetherToken']

Contract Object

A Contract object has:

  • name: str: The name of the contract
  • functions: list[Function]: A list of functions
  • modifiers: list[Modifier]: A list of modifiers
  • all_functions_called: list[Function/Modifier]: A list of all internal functions reachable by the contract
  • inheritance: list[Contract]: A list of inherited contracts (c3 linearization order)
  • derived_contracts: list[Contract]: contracts derived from it
  • get_function_from_signature(str): Function: Returns a Function from its signature
  • get_modifier_from_signature(str): Modifier: Returns a Modifier from its signature
  • get_state_variable_from_name(str): StateVariable: Returns a StateVariable from its name
  • state_variables: List[StateVariable]: list of accessible variables
  • state_variables_ordered: List[StateVariable]: all variable ordered by declaration

Example

from slither import Slither
sl = Slither("0xdac17f958d2ee523a2206206994597c13d831ec7")
compilation_unit = sl.compilation_units[0]

# Print all the state variables of the USDT token
contract = compilation_unit.get_contract_from_name("TetherToken")[0]
print([str(v) for v in contract.state_variables])
% python test.py
['owner', 'paused', '_totalSupply', 'balances', 'basisPointsRate', 'maximumFee', 'allowed', 'MAX_UINT', 'isBlackListed', 'name', 'symbol', 'decimals', 'upgradedAddress', 'deprecated']

Function object

A Function or a Modifier object has:

  • name: str: The name of the function
  • contract: Contract: The contract where the function is declared
  • nodes: list[Node]: A list of nodes composing the CFG of the function/modifier
  • entry_point: Node: The entry point of the CFG
  • [state |local]_variable_[read |write]: list[StateVariable]: A list of local/state variables read/write
    • All can be prefixed by “all_” for recursive lookup
    • Ex: all_state_variable_read: return all the state variables read in internal calls
  • slithir_operations: List[Operation]: list of IR operations
from slither import Slither
sl = Slither("0xdac17f958d2ee523a2206206994597c13d831ec7")
compilation_unit = sl.compilation_units[0]
contract = compilation_unit.get_contract_from_name("TetherToken")[0]

transfer = contract.get_function_from_signature("transfer(address,uint256)")

# Print all the state variables read by the transfer function
print([str(v) for v in transfer.state_variables_read])
# Print all the state variables read by the transfer function and its internal calls
print([str(v) for v in transfer.all_state_variables_read])
% python test.py
['deprecated', 'isBlackListed', 'upgradedAddress']
['owner', 'basisPointsRate', 'deprecated', 'paused', 'isBlackListed', 'maximumFee', 'upgradedAddress', 'balances']

Node object

Node

To explore the nodes:

  • If order does not matter
    • for node in function.nodes
  • If order matters, walk through the nodes
def visit_node(node: Node, visited: List[Node]):

    if node in visited:
        return
    visited += [node]

    # custom action
    for son in node.sons:
        visit_node(son, visited)
  • If need to iterate more than once (advanced usages)
  • Bound the iteration X times
  • Create a fix-point - abstract interpretation style analysis

SlithIR

  • slither/slithir
  • Every IR operation has its own methods
  • Check if an operation is of a type:
    • isinstance(ir, TYPE)
    • Ex: isinstance(ir, Call)
  • Check if the operation is an addition
  • isinstance(ir, Binary) & ir.type == BinaryType.ADDITION
  • Check if the operation is a call to MyContract
  • isinstance(ir, HighLevelCall) & ir.destination == MyContract
from slither import Slither
sl = Slither("0xdac17f958d2ee523a2206206994597c13d831ec7")
compilation_unit = sl.compilation_units[0]
contract = compilation_unit.get_contract_from_name("TetherToken")[0]
totalSupply = contract.get_function_from_signature("totalSupply()")

# Print the external call made in the totalSupply function
for ir in totalSupply.slithir_operations:
    if isinstance(ir, HighLevelCall):
        print(f"External call found {ir} ({ir.node.source_mapping})")
% python test.py
External call found HIGH_LEVEL_CALL, […]   (...TetherToken.sol#339)

Example: Print Basic Information

print_basic_information.py demonstrates how to print basic information about a project.

Exercise 1: Function Overridden Protection

The goal is to create a script that performs a feature that was not present in previous version of Solidity: function overriding protection.

exercises/exercise1/coin.sol contains a function that must never be overridden:

_mint(address dst, uint256 val)

Use Slither to ensure that no contract inheriting Coin overrides this function.

Use solc-select install 0.5.0 && solc-select use 0.5.0 to switch to solc 0.5.0

Proposed Algorithm

Get the Coin contract
    For each contract in the project:
        If Coin is in the list of inherited contracts:
            Get the _mint function
            If the contract declaring the _mint function is not Coin:
                A bug is found.

Tips

  • To get a specific contract, use slither.get_contract_from_name (note: it returns a list)
  • To get a specific function, use contract.get_function_from_signature

Solution

See exercises/exercise1/solution.py.

Exercise 2: Access Control

The exercises/exercise2/coin.sol file contains an access control implementation with the onlyOwner modifier. A common mistake is forgetting to add the modifier to a crucial function. In this exercise, we will use Slither to implement a conservative access control approach.

Our goal is to create a script that ensures all public and external functions call onlyOwner, except for the functions on the whitelist.

Proposed Algorithm

Create a whitelist of signatures
Explore all the functions
    If the function is in the whitelist of signatures:
        Skip
    If the function is public or external:
        If onlyOwner is not in the modifiers:
            A bug is found

Solution

Refer to exercises/exercise2/solution.py for the solution.

Exercise 3: Find function that use a given variable in a condition

The exercises/exercise3/find.sol file contains a contract that use my_variable variable in multiple locations.

Our goal is to create a script that list all the functions that use my_variable in a conditional or require statement.

Proposed Approach

Explore all the helpers provided by Function object to find an easy way to reach the goal

Solution

Refer to exercises/exercise3/solution.py for the solution.

Trail of Bits Blog Posts

The following contains blockchain-related blog posts made by Trail of Bits.

Consensus Algorithms

Research in the distributed systems area

DateTitleDescription
2021/11/11Motivating global stabilizationReview of Fischer, Lynch, and Paterson’s classic impossibility result and global stabilization time assumption
2019/10/25Formal Analysis of the CBC Casper Consensus Algorithm with TLA+Verification of finality of the Correct By Construction (CBC) PoS consensus protocol
2019/07/12On LibraBFT’s use of broadcastsLiveness of LibraBFT and HotStuff algorithms
2019/07/02State of the Art Proof-of-Work: RandomXSummary of our audit of ASIC and GPU-resistant PoW algorithm
2018/10/12Introduction to Verifiable Delay Functions (VDFs)Basics of VDFs - a class of hard to compute, not parallelizable, but easily verifiable functions

Fuzzing Compilers

Our work on the topic of fuzzing the solc compiler

DateTitleDescription
2021/03/23A Year in the Life of a Compiler Fuzzing CampaignResults and features of fuzzing solc
2020/06/05Breaking the Solidity Compiler with a FuzzerOur approach to fuzzing solc

General

Security research, analyses, announcements, and write-ups

DateTitleDescription
2022/10/12Porting the Solana eBPF JIT compiler to ARM64Low-level write-up of the work done to make the Solana compiler work on ARM64
2022/06/24Managing risk in blockchain deploymentsA summary of "Do You Really Need a Blockchain? An Operational Risk Assessment" report
2022/06/21Are blockchains decentralized?A summary of "Are Blockchains Decentralized? Unintended Centralities in Distributed Ledgers" report
2020/08/05Accidentally stepping on a DeFi legoWrite-up of a vulnerability in yVault project
2020/05/15Bug Hunting with CryticDescription of 9 bugs found by Trail of Bits tools in public projects
2019/11/13Announcing the Crytic $10k Research PrizeAcademic research prize promoting open source work
2019/10/24Watch Your Language: Our First Vyper AuditPros and cons of Vyper language and disclosure of vulnerability in the Vyper's compiler
2019/08/08246 Findings From our Smart Contract Audits: An Executive SummaryPublication of data aggregated from our audits. Discussion about possibility of automatic and manual detection of vulnerabilities, and usefulness of unit tests
2018/11/19Return of the Blockchain Security Empire Hacking
2018/02/09Parity Technologies engages Trail of Bits
2017/11/06Hands on the Ethernaut CTFFirst write-up on Ethernaut

Guidance

General guidance

DateTitleDescription
2021/02/05Confessions of a smart contract paper reviewerSix requirements for a good research paper
2018/11/2710 Rules for the Secure Use of Cryptocurrency Hardware WalletsRecommendations for the secure use of hardware wallets.
2018/10/04Ethereum security guidance for allAnnouncement of office hours, Blockchain Security Contacts, and Awesome Ethereum Security
2018/04/06How to prepare for a security reviewChecklist for before having a security audit

Presentations

Talks, videos, and slides

DateTitleDescription
2019/01/18Empire Hacking: Ethereum Edition 2Talks include: Anatomy of an unsafe smart contract programming language, Evaluating digital asset security fundamentals, Contract upgrade risks and recommendations, How to buidl an enterprise-grade mainnet Ethereum client, Failures in on-chain privacy, Secure micropayment protocols, Designing the Gemini dollar: a regulated, upgradeable, transparent stablecoin, Property testing with Echidna and Manticore for secure smart contracts, Simple is hard: Making your awesome security thing usable
2018/11/16Trail of Bits @ Devcon IV RecapTalks include: Using Manticore and Symbolic Execution to Find Smart Contract Bugs, Blockchain Autopsies, Current State of Security
2017/12/22Videos from Ethereum-focused Empire HackingTalks include: A brief history of smart contract security, A CTF Field Guide for smart contracts, Automatic bug finding for the blockchain, Addressing infosec needs with blockchain technology

Tooling

Description of our tools and their use cases

DateToolTitleDescription
2022/08/17slitherUsing mutants to improve SlitherInserting random bugs into smart contracts and detecting them with various static analysis tools - to improve Slither's detectors
2022/07/28slitherShedding smart contract storage with SlitherAnnouncement of the slither-read-storage tool
2022/04/20Amarna: Static analysis for Cairo programsOverview of Cairo footguns and announcement of the new static analysis tool
2022/03/02echidnaOptimizing a smart contract fuzzerMeasuring and improving performance of Echidna (Haskell code)
2021/12/16slitherDetecting MISO and Opyn’s msg.value reuse vulnerability with SlitherDescription of Slither's new detectors: delegatecall-loop and msg-value-loop
2021/04/02Solar: Context-free, interactive analysis for SolidityProof-of-concept static analysis framework
2020/10/23slitherEfficient audits with machine learning and Slither-similDetect similar Solidity functions with Slither and ML
2020/08/17echidnaUsing Echidna to test a smart contract libraryDesigning and testing properties with differential fuzzing
2020/07/12manticoreContract verification made easierRe-use Echidna properties with Manticore with manticore-verifier
2020/06/12slitherUpgradeable contracts made safer with Crytic17 new Slither detectors for upgradeable contracts
2020/03/30echidnaAn Echidna for all SeasonsAnnouncement of new features in Echidna
2020/03/03manticoreManticore discovers the ENS bugUsing symbolic analysis to find vulnerability in Ethereum Name Service contract
2020/01/31manticoreSymbolically Executing WebAssembly in ManticoreUsing symbolic analysis on an artificial WASM binary
2019/08/02Crytic: Continuous Assurance for Smart ContractsNew product that integrates static analysis with GitHub pipeline
2019/07/03slitherAvoiding Smart Contract "Gridlock" with SlitherDescription of a DoS vulnerability resulting from a strict equality check, and Slither's dangerous-strict-equality detector
2019/05/27slitherSlither: The Leading Static Analyzer for Smart ContractsSlither design and comparison with other static analysis tools
2018/10/19slitherSlither – a Solidity static analysis frameworkIntroduction to Slither's API and printers
2018/09/06rattleRattle – an Ethereum EVM binary analysis frameworkTurn EVM bytecode to infinite-register SSA form
2018/05/03echidnaState Machine Testing with EchidnaExample use case of Echidna's Haskell API
2018/03/23Use our suite of Ethereum security toolsOverview of our tools and documents: Not So Smart Contracts, Slither, Echidna, Manticore, EVM Opcode Database, Ethersplay, IDA-EVM, Rattle
2018/03/09echidnaEchidna, a smart fuzzer for EthereumFirst release and introduction to Echidna
2017/04/27manticoreManticore: Symbolic execution for humansFirst release and introduction to Manticore (not adopted for EVM yet)

Upgradeability

Our work related to contracts upgradeability

DateTitleDescription
2020/12/16Breaking Aave UpgradeabilityDescription of Delegatecall Proxy vulnerability in formally-verified Aave contracts
2020/10/30Good idea, bad design: How the Diamond standard falls shortAudit of Diamond standard's implementation
2018/10/29How contract migration worksAlternative to upgradability mechanism - moving data to a new contract
2018/09/05Contract upgrade anti-patternsDiscussion of risks and recommendations for Data Separation and Delegatecall Proxy patterns. Disclosure of vulnerability in Zeppelin Proxy contract.

Zero-Knowledge

Our work in Zero-Knowledge Proofs space

DateTitleDescription
2022/04/18The Frozen Heart vulnerability in PlonK
2022/04/15The Frozen Heart vulnerability in Bulletproofs
2022/04/14The Frozen Heart vulnerability in Girault’s proof of knowledge
2022/04/13Coordinated disclosure of vulnerabilities affecting Girault, Bulletproofs, and PlonKIntroducing new "Frozen Heart" class of vulnerabilities
2021/12/21Disclosing Shamir’s Secret Sharing vulnerabilities and announcing ZKDocs
2021/02/19Serving up zero-knowledge proofsFiat-Shamir transformation explained
2020/12/14Reverie: An optimized zero-knowledge proof systemRust implementation of the MPC-in-the-head proof system
2020/05/21Reinventing Vulnerability Disclosure using Zero-knowledge ProofsAnnouncement of DARPA sponsored work on ZK proofs of exploitability
2019/10/04Multi-Party Computation on Machine LearningImplementation of 3-party computation protocol for perceptron and support vector machine (SVM) algorithms