Ensure the security of your smart contracts

Audit of Substrate Pallets.
Overview & Tips

Author: Alexey Naberezhniy
Security researcher at MixBytes
Polkadot has emerged as a thriving ecosystem, attracting a growing number of projects. With its long-standing record of active development, it continues to lead the way in terms of innovation. Despite its success, there is a shortage of companies capable of auditing projects built on the Substrate platform. Additionally, the auditing of pallets remains an under-standardized process with limited information available.

In an effort to shed light on this important topic, we conducted an independent study of the primary vulnerabilities in projects built on the Substrate platform and the tools available for automating the audit process. As a supplement to our findings, we present a comprehensive analysis of the Acala parachain's liquid staking module, where no critical issues were identified but several valuable recommendations were made.
Typical bugs in Substrate pallets
1. Method on_initialized
This method calls each new block in a pallet. This method must not be loaded (or dependent on variables that can cause a load) and must not cause panic. Otherwise, there will be DoS.
2. The returned result is not handled / Runtime panic conditions
The result of a method may not be processed or may cause panic.
The unwrap method is used by the Option class to work safely with None. unwrap causes panic if there is None in the object. This is critical for blockchain infrastructure.

Instead, use unwrap_or_default.

It's also a common case that the result of try_mutate_exists is ignored. This is not an error, but may cause side effects if the method returns None.
3. Bad extrinsics' weights
The use of blockchain resources must be limited for uninterrupted network operation. Substrate uses a weighting mechanism to control the operating time of extrinsic (like gas on Ethereum). If the weights are set incorrectly, then spam transactions can cause DoS of a node.
The most telling example is a loop.

#[weight = 1_000_000]
pub fn work_with_users(origin: OriginFor<T>, users: Vec<T::AccountId>) -> DispatchResult {
    let _ = ensure_signed(origin)?;

    for user in users {

As you can see above, the weight of the work_with_users method does not depend on the actual complexity of the method execution (the number of loop runs is not taken into account).
4. Overflow
There are several ways to work with numbers in Rust.

  • The most unsafe of these are raw mathematical operations (e.g. +) which can overflow.
  • saturating_add (at the numeric bounds instead of overflowing, the returned result is inaccurate). It does not cause an overflow, but has subtleties that must be taken into account when working (for example, 0 - 1 = 0 when working with unsigned).
  • You can use checked_add / safe_add to check overflows in the calculation.

As in any other programming language, when it comes to operations with user input, then checked_add is preferable.
5. Need to use transactional
[transactional] must be used for every extrinsic in the pallet, otherwise the state will not be canceled on revert.

Btw, in new versions of substrate "transactional" is a default behavior.

Self::set_balance(currency_id, who, amount); # setter for storage
Err("some revert")?;
Without transactional after revert the balance record will not be deleted.
6. Classical vulnerabilities (logic bugs) from EVM:
  • Slippage on swaps
  • Approve front-run - you can make several approves and get Approve1 + Approve2
  • The Emergency shutdown mechanism has excessive authority and may cause adverse effects
  • Address not verified (like require(owner != address(0x0)))
  • and many other bugs
Substrate is a completely different technology, but errors are quite standard for any smart contract https://medium.com/acalanetwork/acala-incident-report-14-08-2022-392089588642.

However, there are very important differences between Substrate and EVM audits:

  1. The user cannot upload a contract with arbitrary logic to the network and perform complex actions in one transaction (however, it is possible to call several sequential methods in a batch);
  2. Only calls to public methods of pallets are available to a hacker;
  3. It will not be possible to increase the profit from hacking, since the implementation of flashloan cannot be supported in the usual sense due to the impossibility of passing a callback to a function.
These features shorten the list of manipulations that a hacker can perform, and therefore reduce the number of attack vectors that auditors must study when examining a project on Substrate.
7. XCM messages
XCM messages may have the following issues:

  • Incorrect privileges when sending XCM (this problem can cause an attacker to send any transactions from a parachain address in a relay chain)
  • Content of incorrect "commands"
8. Interaction with pallets
Typically, a single parachaine uses multiple pallets that interact with each other and with which users interact. Developers must be very careful and provide all tests that are guaranteed to protect against the situation of incorrect access of one pallet to another (unforeseen rewriting of the state). Since palettes are analogous to smart contracts in EVM, access to the storage of one pallet from another must occur safely.
For example, developers use pallet_sudo for hot fixes, which is not a safe approach when working with the blockchain.
9. Incorrect migration of Parachain to new version
The migration should take into account all new additions of fields in the pallet, and also check the correctness of the transition to the new version during migration.
There aren't many tools out there to make interacting with Rust easier (in the context of analysis).

  • cargo-audit (https://rustsec.org)
    The RustSec Advisory Database is a repository of security advisories filed against Rust crates published via crates.io maintained by the Rust Secure Code Working Group.

  • Static analyzers (https://github.com/rust-lang/rust-analyzer)
    One of the examples of static analyzers is rust-analyzer. It is a modular compiler frontend for the Rust language. It is a part of a larger rls-2.0 effort to create excellent IDE support for Rust.

  • Debugging (via LLDB)
    A well-known method for detecting code errors using LLDB debugging.

  • Testing (cargo test)
    Writing tests and checking them will help you to be sure that all scripts available for the system will work correctly. Integration tests between pallets are particularly important.

  • Fast fork (https://github.com/maxsam4/fork-off-substrate)
    This script allows bootstrapping a new substrate chain with the current state of a live chain. Using this, you can create a fork of Polkadot, Kusama and other substrate chain for development purposes.

  • https://docs.substrate.io/test/check-runtime/
    The try-runtime command-line tool enables you to query a snapshot of runtime storage using an in-memory-externalities data structure to store state. By using the in-memory storage, you can write tests for a specified runtime state so that you can test against real chain state before going to production.
Studied Attack Vectors for Acala Liquid Staking Module (Bonus)
commit: 37560835d477ee934fbca094af723d7d98b5f1d8
1. Validation of input parameters in admin functions
The methods below do not sufficiently check the validity of the input parameters, which can lead to DoS of the system for some time:

  • update_homa_params
  • update_bump_era_params
  • force_bump_current_era

For example:

  • get_staking_currency_soft_cap == 0 will stop the ability to call the do_mint method
  • A large estimated_reward_rate_per_era rate may lead to incorrect profit calculation.
  • bump_era_frequency may block the service (there will be no call to on_initialize)
  • force_bump_current_era allows you to jump to ANY era.

All these methods are admin methods, so there is no direct threat to the protocol, since usually all calls from the administrator are performed with multiple parameter checks, however, we still recommend adding checks to increase system security.
2. Mathematical operations with LDOT balances
Can users burn more LDOT than the number of tokens in the pallet?

Not possible here because actual_liquid_to_redeem is ALWAYS LESS than Self::to_bond_pool().

total_redeem_amount = sum(RedeemRequests.redeem_amount) => By this time LDOT is already 100% loaded on the contract because there is no other way to call do_request_redeem
3. Conversion rate between DOT and LDOT
Is it possible to artificially change the ratio in the convert_liquid_to_staking or convert_staking_to_liquid methods?

No, because the ratio does not change dramatically.

let total_staking = Self::get_total_staking_currency();
let total_liquid = Self::get_total_liquid_currency();
Change in methods:

  • process_staking_rewards (rewards are awarded for each era). Doesn't affect much
  • fast_match_redeems (withdrawal fee is taken). Doesn't affect much
  • reset_ledgers (can significantly change total_staking). But it is only available for GovernanceOrigin and is also a protocol limitation.

The rest of the price change occurs proportionally:

  • process_redeem_requests (burns staking and liquid)
  • mint (adds staking and liquid)
The information contained in this Website is for educational and informational purposes only and shall not be understood or construed as financial or investment advice.
Other posts