Introduction to Auditing and our smart contract audit process

Extropy.IO
12 min readAug 11, 2021

In light of the recent $611 million exploit of PolyNetwork we present a short overview of smart contract auditing and explain our audit process. Extropy is working on tools to give more insight into exploits based on tokenomics, how these can be detected and avoided. For further information about recent exploits please visit our security bulletin, and sign up to our monthly newsletter.

1. What are smart contract audits?

A smart contract audit is a thorough investigation and examination of a smart contract’s code. An audit aims to uncover any errors, vulnerabilities and security issues and provide remediation or suggestions on how they can be addressed.

However, smart contract audits are not just for testing against possible attacks. They also:

  • Provide checks on the code quality and consistency
  • Analyse the code for common errors (variable types errors, compilation errors etc.)

Security audits are necessary as frequently smart contracts deal with financial assets and therefore rigorous checking needs to take place to ensure the safety of those assets.

Smart contracts tend to be complex in nature, normally interacting with other contracts and third party systems. This adds an additional level of potential security implications and therefore audits check those interactions through code analysis and testing.

2. Types of auditing

There are two types of auditing that are used to check smart contract code. A well rounded audit will consist of both:

Manual Auditing

Manual auditing generally involves a team of auditors that analyse each line of code and check it for possible security issues. This approach is generally seen as the most accurate as it not only provides checking for security errors but also on contract logic. We would also check that the contract does not violate the original intended behaviour that has been outlined by the client.

Automated Auditing

Automated auditing is done by using a wide range of tools that can test for vulnerabilities much quicker than manually auditing a contract. These tools can not only check for existing bugs in the code but also where potential bugs could occur, providing a proactive approach to security management. However, these tools are not foolproof. The speed at which they run can cause them to miss security flaws and also highlight code that there are no issues with.

3. The benefits of auditing

Benefit Reason Risk Identification Highlighting any security issues in the contract before it is launch on the blockchain. This can then be address proactively before any serious issues arise (ie. exploits). Code Improvements Checking the quality of the code including adherence to best practices ensures that code is easily readable and maintainable. It also helps to produce a more secure and robust contract. Gas Optimisation An audit can check to see how efficient the contract is with gas and help to provide solutions to improve gas costs. Performance Checking contract executions and any variations that may occur can check for possible unintended outcomes that may arise and to enhance performance Credibility Showing that your contract has passed an audit can offer some credibility to users that the contract is working as intended (however this should not be taken as a guarantee) Compliance A smart contract may need an audit as part of compliance on performing regulated activity(Cryptoassets: AML/CFT regime)

4. Common exploits

Reentrancy

Reentrancy attacks are a result of a contract performing an action before updating the corresponding state value. A basic example of this is withdrawing money from a bank before updating the users account balance. Take this example:

function withdraw(uint256 amount) public returns (uint256) { require(amount <= balance[msg.sender]); require(msg.sender.call.value(amount)());
balance[msg.sender] -= amount;
return balance[msg.sender];
}

Updating the user’s balance does not occur until the end of the function. If another smart contract calls this smart contract with the following logic:

function reentrancyAttack() public payable { targetAddress.withdraw(amount); 
}
function () public payable {
if(address(targetAddress).balance >= amount) { targetAddress.withdraw(amount);
}
}

When the reentrancyAttack function is called it will call the withdraw method and when it comes back, it will call the fallback function. This, as can be seen above, would call the other function again, without triggering the balance update. This then loops and drains the balance of the smart contract as a whole.

Transaction-Ordering Dependence (Front Running)

Front running is manipulating the position of transactions within a block. Transactions sit in the mempool for a short amount of time before they are included in a block. These transactions can be seen by anyone. If, for example there was a lucrative arbitrage opportunity, a transaction could be identified in the mempool, a identical transaction could be placed in the mempool except with a higher gas amount and this would be taken first. The transaction would be executed and that opportunity would have been taken advantage of, the market would have corrected, and the original users transaction would be rendered useless.

___

___

Integer Underflow/Overflow

Take the below example:

mapping(address => uint256) public balance; function transfer(address _recipient, uint256 _amount) { require(balance[msg.sender] >= _amount); 
balance[msg.sender]-= _amount;
balance[_recipient] += _amount;
}

If one of the user balances exceeds the maximum value of the uint256 , it will cause the balance to wrap around to zero which may cause an issue with some require statements (checking for greater than zero). This can also occur in the opposite direction as an integer underflow. If the uint is made to be less than zero it will default to its maximum value.

Block Gas Limit Vulnerabilities

In Ethereum, blocks are limited in the amount of gas that a transaction can consume. If a transaction costs too much gas, it will be too big to fit into a block and will never be executed. Therefore, a block gas limit is in place to stop blocks from becoming too big.

This can provide a method for a DoS attack on the contract. For example, if a function pays out an array of addresses, if the array is too big, it will consume all of the block gas limit and fail. This can be used a malicious parties to cause a blocked transaction indefinitely which could stop any other transactions from ever being processed.

Another way this can be done is by a method called block stuffing. This is where a malicious actor puts transactions into a block set with a very high gas price. This will ‘stuff’ the block and make it so that other transactions cannot fit into the block due to the gas limit already being reached.

DoS with (unexpected) revert

This can happen when you use a function to send funds to another user and the functionality requires that the transaction is processed successfully.

A malicious party can create a smart contract with a fallback function that reverts all payments. The bad actor can then get the function in the initial contract to pay this smart contract.

Take the below example:

contract Auctioneer { uint256 currentHighestBid; 
address currentHighestBidder;
function bid() payable {
//new value check
require(msg.value > currentHighestBid);
//return funds require(currentHighestBidder.send(currentHighestBid)); //modify higher bidder information
currentHighestBidder = msg.sender;
currentHighestBid = msg.value;
}
}

The first require statement ensures that the value of the new bid is higher than the current highest bid amount. The second require statement sends the current highest bidder back their amount. Then it replaces the highest bidder and current highest bid with the new information.

However, if refunding the previous highest bidder fails, then the function as a whole will fail and the highest bidder with remain the same as before. Therefore if a malicious actor bids a new amount from a smart contract with a fallback function that reverts all of the payments, the highest bidder will never been replaced ensuring that nobody can make a higher bid.

5. Our auditing process

  1. Review source code and scope of the audit and agree timescale and price
  2. Check the code manually to ensure that the logic is resistant to common attack vectors.
  3. Use tools to check the contracts for vulnerabilities.
  4. Debrief with team to discuss findings.
  5. Creation of an audit report that highlights any security risk to the project and its users and recommend remediation.

What we look for during an audit

Sound architecture

Assessments of the overall architecture and design choices. Given the subjective nature of these assessments, it will be up to the development team to determine whether any changes should be made.

Smart contract best practices

Checking to see whether the codebase follows the current established best practices for smart contract development. Consensys — Smart Contract Best Practices

Code Correctness

Establishing whether the code currently meets its intended purpose. This should be established with the client pre-audit so that there is some understanding of the project and therefore of the codes purpose. This can also be highlighted by pre-existing testing conducted on the contract.

Code Quality

Evaluating the code to check whether it has been written in a way that ensures readability and maintainability. Reducing highly complex or over-engineered code can help reduce the potential for exploits, reduces the amount of gas consumed and makes auditing the contract much easier.

Security

Checking for exploitable security vulnerabilities, or other potential threats to the users. We categorise these into different security levels.

Security Level Definition Critical Issue ranked as very serious and dangerous for users and the secure working of the system. It is likely to lead to risk of exposure of sensitive information and of serious financial ramifications for the client and user. Needs immediate improvements and further checking to ensure it has been remedied. High Issue ranked as serious which could lead to unreliable working of the system and has potential to cause moderate financial impact and/or sensitive information leaks. Needs immediate improvements and further checking to ensure it has been remedied. Medium Issue ranked as a medium risk could lead to a potential for a financial loss and a risk of leaking sensitive client and user information. Should be addressed. Low Issue ranked as low has a relatively small chance of being exploited. The issue does not pose an immediate operational threat however it is not in line with best practices.

Testing and Testability

Reviewing to see how rigorously the code has been tested and how easy it is for the code to be tested. A smart contract that has been tested with high coverage (as close to 100% as possible), provides some level of proof that the smart contract is working as intended. This also reduces the amount of time the auditor has to focus on checking functionality and more time analysing security issues.

Gas Optimisation

Gas optimisation is an important part of an efficient smart contract. We check the current gas consumption of the smart contracts to ensure that they waste as little gas as possible to reduce operational costs.

Memory vs Storage

Performing operations using memory(call data), is always cheaper than using storage.

A standard way of reducing the amount of storage operations is to change the data before assigning it to storage.

The most common way of seeing this implemented is in loops:

//SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

contract multiplication {

uint256 amount = 13;
uint256 newAmount;

function multiplyAmount(uint256 multiplyBy) external {
uint256 multipliedAmount;
for (uint256 ii = 0; ii < multiplyBy; ii++) {
multipliedAmount += amount;
}
newAmount = multipliedAmount;
}

function getNewAmount() public view returns (uint256) {
return newAmount;
}
}

In the multiplication contract, we use the local memory variable multipliedAmount to store intermediate values and then assign the final value to our storage variable newAmount.

However, there are some caveats to this: Copying between memory and storage will cost gas, so don’t copy arrays from storage to memory, use a storage pointer. Obviously some data needs to persist between function calls. The cost of memory is complicated, you “buy” it in chunks, the cost of which will go up quadratically after a while.

Mapping vs. Array

It is nearly always better to use a mapping instead of an array because of its cheaper operations.

However, an array can be the correct choice when using smaller data types. Array elements are packed like other storage variables and the reduced storage space can outweigh the cost of an array’s more expensive operations.

This is most useful when working with large arrays.

Packing Variables The way we arrange variables can impact the amount of gas that we are using. Take this code for example:

struct myStruct {
uint64 number1;
uint128 number2;
uint256 number3;
uint64 number4;
}

When storing data permanently on the blockchain, the assembly command SSTORE is executed in the background. This is one of the most expensive commands on the contract (cost of 20,000 gas). The code above is expensive as it needs to call SSTORE 3 times (as each storage slot has 256 bits): uint64 + uint128 2. uint256 3. uint64

A better way to structure the code would be:

struct myStruct { 
uint64 number1;
uint64 number4;
uint128 number2;
uint256 number3;
}

SSTORE would only be called twice on this code as the first 3 variables can be stacked in the same storage slot: 1. uint64 + uint64 + uint128 2. uint256

Therefore, how you structure your variable assignment can also save you on gas costs.

Gas Refunds

You can receive a refund of 15000 gas by freeing up storage slots (zeroing corresponding variables).

Completely removing a contract (by using the SELFDESTRUCT opcode) rebates 24,000 gas.

The completion of the contract execution is the only time when refunds occur, thus contracts are unable to pay for themselves. In addition, a refund must not surpass half the gas that the ongoing contract call uses.

Fixed vs Dynamic Variables

It is always cheaper to used Fixed variables as opposed to Dynamic variables. For example, if the length of a variable is known then we can specify it:

string[7] daysOfTheWeek;

Initialising Variables

This:

uint256 randomNumber = 0; 

costs more gas than:

uint256 randomNumber;

and both lines of code accomplish the same thing. You should also use bytes32 over string as this is cheaper.

Importing Libraries

Libraries are normally imported so that we can use specific elements from them. These libraries can be massive and contain much more code than we want to use. We can specify which aspects of the library we would like to use, which makes the contract more optimal.

import ‘./SafeMath.sol’ as safeMath;contract SafeCalculations { function computeSubtraction(uint256 x, uint256 y) public view returns(uint256) { 
return safeMath.sub(x, y);
}
}

Require and Assert

require should be used for all runtime condition validations that can’t be pre-validated on the compile time.

assert should be used only for static validations that normally never fail in a properly functioning code.

A failing assert consumes all the gas available to the call, while require doesn’t consume any.

Hash Functions

keccak256: 30 gas + 6 gas for each word of input data sha256: 60 gas + 12 gas for each word of input data ripemd160: 600 gas + 120 gas for each word of input data

So if you don’t have specific reasons to select another hash function, just use keccak256.

Tools we use to support our process

There are a range of tools that can be used to aid in the analysis of smart contracts. Some of the tools we use:

Timescales

This will vary upon the scope of the audit and the complexity of the project. For a small audit (less complex) normally 1–2 weeks. For a larger audit (more complex) normally 2–4 weeks. This will be agreed upon with the client in the initial scope of work.

6. Complimentary processes for ensuring code security

Regular Auditing

It is important to regularly audit your smart contracts. There are always new attacks vectors that are being used to breach the security of contracts and therefore it would be worthwhile checking for to maintain protection.

Also, if any changes are made to contracts (either new contracts being added to the project or anything changed through upgradability), the smart contracts should be rechecked to ensure security.

Bug Bounties

Bug bounties are a tool for rewarding friendly hackers for uncovering security vulnerabilities in your smart contract. This incentivises hackers to try to exploit your code receive recognition and a bounty (normally paid out in native tokens or ETH), which further helps audit your contracts.

7. Disclaimer

Smart contract audits should not be taken as a security warranty as the total number of test cases are unlimited.

Smart contracts are analysed in accordance with the current industry best practices at the date of the report.

An audit does not guarantee security or correctness and it is only one part of a well rounded smart contract security protocol.

--

--

Extropy.IO

Oxford-based blockchain and zero knowledge consultancy and auditing firm