Lesson 6 - Address Validation

Introduction

In this lesson, you will learn the importance of validating the recipient address whenever a token is transferred to any address. The lack of address validation may result in lost or locked tokens if default addresses are used mistakenly on production chains.

Prerequisites

To be able to understand and complete this lesson, we believe that the minimum requirement should be the completion of Lesson 1 - Getting Started. If you desire to be even more prepared please have a look at the official website of ink!.

Objectives and Outcomes

In this lesson you will learn:

  • The consequences of not checking the recipient address against a list of known bad addresses;

  • How to attack a smart contract by using the lack of address validation;

  • How to mitigate the attack.

Exercise

Vulnerable Smart contract

A token contract that allows transfer to any addresses, without validation, is vulnerable. The following transfer function does not perform any address validations on the user-provided token recipient address and may result in lost or locked tokens if default addresses are used mistakenly on production chains.

        #![cfg_attr(not(feature = "std"), no_std)]

#[ink::contract]
mod s0token {
    use ink::{storage::Mapping};

    #[ink(storage)] 
    pub struct S0token {
        total_supply: u32,
        balances: Mapping<AccountId, u32>
    }

    impl S0token {
        /// Creates a token contract with the given initial supply belonging to the contract creator
        #[ink(constructor)]
        pub fn new_token(supply: u32) -> Self {
            let mut balances = Mapping::default();
            let caller = Self::env().caller();
            balances.insert(&caller,&supply);
            Self {
                total_supply: supply,
                balances
            }
        }

        /// Total Supply of s0token
        #[ink(message)]
        pub fn total_supply(&self) -> u32 {
            self.total_supply
        }

        /// Current balance of the chosen account.
        #[ink(message)]
        pub fn balance_of(&self, account: AccountId) -> u32 {
            match self.balances.get(&account) {
                Some(value) => value,
                None => 0,
            }
        }

        /// Transfers an amount of tokens to the chosen recipient.
        #[ink(message)]
        pub fn transfer(&mut self, recipient: AccountId, amount: u32) {
            let sender = self.env().caller();
            let sender_balance = self.balance_of(sender);
            if sender_balance < amount {
                return;
            }
            self.balances.insert(sender, &(sender_balance - amount));
            let recipient_balance = self.balance_of(recipient);
            self.balances.insert(recipient, &(recipient_balance + amount));
        }

    }

}

Unfortunately, Bob did not follow the security guidelines and this has for consequences that his contract is vulnerable. Do you see where the security vulnerability occurs?

==- Hint First, identify lines where the check of the function caller is done.

==- Answer The validation of the address is not done correctly.

Simulated Attack

==- Reveal Attack

Setup

When developing contracts in Ink, there are a number of addresses that are used for development purposes locally and on the testnet. However, use of these addresses on the production network can result in lost gas fees and unretrievable tokens and AZERO if accidentally used within that environment. A list of these accounts can be found in the ink_env test documentation.

https://paritytech.github.io/ink/ink_env/test/struct.DefaultAccounts.html

cargo install cargo-contract --version 2.0.0-beta.1
cargo +nightly test -- --nocapture

Tests Output

running 3 tests
Caller: AccountId([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1])
Recipient: AccountId([2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2])
Account Balance: 1000
test s0token::tests::total_supply_works ... ok
test s0token::tests::balance_of_works ... ok
test s0token::tests::transfer_works ... ok

Build and Deploy to Testnet

Using Cargo Contract Command line

cargo +nightly contract build --release
export SEED="[put your 12 words seed phrase here]"
export URL="wss://ws.test.azero.dev"
cargo contract instantiate --suri "$SEED" --url "$URL" \
        --constructor new_token \
        --args 10000

Output

 Dry-running new_token (skip with --skip-dry-run)
    Success! Gas required estimated at Weight(ref_time: 489377768, proof_size: 0)
Confirm transaction details: (skip with --skip-confirm)
 Constructor new_token
        Args 10000
   Gas limit Weight(ref_time: 489377768, proof_size: 0)
Submit? (Y/n): 
   Code hash 0xeaae76bb62900ef5c5f17d0802b1c80a7346db0cbab00c729a74f6b1bb6e865c # Code Hash address will be different
    Contract 5GJZow5yovTExpjBEMb8ECrJJ7sutR471gvVTbCETfjcAg6e # Contract address will be different
      Events
      ....
export CONTRACT="<contract address from above>"
export ACCOUNT="<account address that instantiated the contract>"
cargo contract call --suri "$SEED" --url "$URL"  --contract "$CONTRACT"  --message balance_of --args "$ACCOUNT" --dry-run

Output

    Result Success!
    Reverted false
    Data Tuple(Tuple { ident: Some("Ok"), values: [UInt(10000)] })

Transfer 1000 tokens to the Zero Address

cargo contract call --suri "$SEED" --url "$URL"  --contract "$CONTRACT"  --message transfer --args 0x0000000000000000000000000000000000000000000000000000000000000000 1000
cargo contract call --suri "$SEED" --url "$URL"  --contract "$CONTRACT"  --message balance_of --args 0x0000000000000000000000000000000000000000000000000000000000000000 --dry-run   

Output showing 1000 tokens assigned to the zero address

    Result Success!
    Reverted false
    Data Tuple(Tuple { ident: Some("Ok"), values: [UInt(1000)] })

These 1000 tokens are now tied to the zero address account and are not retrievable.

The same thing can occur with the test accounts used for testing purposes, such as Alice or Bob.

Transfer 200 tokens to Alice

cargo contract call --suri "$SEED" --url "$URL"  --contract "$CONTRACT"  --message transfer --args 0x0101010101010101010101010101010101010101010101010101010101010101 250
cargo contract call --suri "$SEED" --url "$URL"  --contract "$CONTRACT"  --message balance_of --args 0x0101010101010101010101010101010101010101010101010101010101010101 --dry-run

Output

    Result Success!
    Reverted false
    Data Tuple(Tuple { ident: Some("Ok"), values: [UInt(250)] })

There is also a case where a contract is instantiated with an empty seed phrase.

Make sure that there is no environment variable for $SEED

echo "$SEED"  

Should return an empty space.

Now instantiate a new contract. The environment variable $SEED can be used or just an empty string "", the reason to use the environment variable is to demonstrate a developer that has opened a new terminal without exporting their SEED and how easy it would be to deploy a contract unknowingly with that address. An attacker could send enough Azero to that address that would pay for the gas to instantiate a new contract and wait for more Azero to be sent to the address or transfer the newly created token.

Open a new terminal without an export environment variables

cargo contract instantiate --suri "" --url "$URL"   --constructor new_token  --args 10000 

Error from no $URL variable

error: invalid value '' for '--url <url>': relative URL without a base

For more information, try '--help'.
export URL="wss://ws.test.azero.dev"

Try again to instantiate the contract

cargo contract instantiate --suri "$SEED" --url "$URL"   --constructor new_token  --args 10000     

Output

   Success! Gas required estimated at Weight(ref_time: 489377768, proof_size: 0)
Confirm transaction details: (skip with --skip-confirm)
 Constructor new_token
        Args 10000
   Gas limit Weight(ref_time: 489377768, proof_size: 0)
Submit? (Y/n): y
      Events
       Event Balances  Withdraw
         who: 5DfhGyQdFobKM8NsWvEeAKk5EQQgYe9AydgJ7rMB6E1EqRzV
         amount: 2.362346462mTZERO
       Event System  NewAccount
         account: 5CZkTpGv3tooQwxfHjYeKxU58nrWwdJR1UxkqukTbY4BAWet
       Event Balances  Endowed
         account: 5CZkTpGv3tooQwxfHjYeKxU58nrWwdJR1UxkqukTbY4BAWet
         free_balance: 153mTZERO
       Event Balances  Transfer
         from: 5DfhGyQdFobKM8NsWvEeAKk5EQQgYe9AydgJ7rMB6E1EqRzV
         to: 5CZkTpGv3tooQwxfHjYeKxU58nrWwdJR1UxkqukTbY4BAWet
         amount: 153mTZERO
       Event Balances  Reserved
         who: 5CZkTpGv3tooQwxfHjYeKxU58nrWwdJR1UxkqukTbY4BAWet
         amount: 153mTZERO
       Event Contracts  Instantiated
         deployer: 5DfhGyQdFobKM8NsWvEeAKk5EQQgYe9AydgJ7rMB6E1EqRzV
         contract: 5CZkTpGv3tooQwxfHjYeKxU58nrWwdJR1UxkqukTbY4BAWet
       Event Balances  Transfer
         from: 5DfhGyQdFobKM8NsWvEeAKk5EQQgYe9AydgJ7rMB6E1EqRzV
         to: 5CZkTpGv3tooQwxfHjYeKxU58nrWwdJR1UxkqukTbY4BAWet
         amount: 72mTZERO
       Event Balances  Reserved
         who: 5CZkTpGv3tooQwxfHjYeKxU58nrWwdJR1UxkqukTbY4BAWet
         amount: 72mTZERO
       Event Balances  Deposit
         who: 5DfhGyQdFobKM8NsWvEeAKk5EQQgYe9AydgJ7rMB6E1EqRzV
         amount: 186.37751μTZERO
       Event Balances  Deposit
         who: 5EYCAe5fg5WiYGVNH6QpCFnu55Hzv9MwtjFHdQCx8EaSQTm2
         amount: 2.175968952mTZERO
       Event Treasury  Deposit
         value: 2175968952
       Event TransactionPayment  TransactionFeePaid
         who: 5DfhGyQdFobKM8NsWvEeAKk5EQQgYe9AydgJ7rMB6E1EqRzV
         actual_fee: 2.175968952mTZERO
         tip: 0TZERO
       Event System  ExtrinsicSuccess
         dispatch_info: DispatchInfo { weight: Weight { ref_time: 2175965258, proof_size: 0 }, class: Normal, pays_fee: Yes }

    Contract 5CZkTpGv3tooQwxfHjYeKxU58nrWwdJR1UxkqukTbY4BAWet
export CONTRACT="<contract address from above>"
export ACCOUNT="<account address that instantiated the contract>"

Empty SEED string call

cargo contract call --suri "" --url "$URL"  --contract "$CONTRACT"  --message balance_of --args $ACCOUNT --dry-run
    Result Success!
    Reverted false
    Data Ok(10000)

==-

Secure Solution

This token contract fixes the lack of address validation by checking that no default or zero address accounts are used as the recipient for receiving tokens.

Mitigation of the lack of address validation is accomplished by checking the recipient address against a list of known bad addresses. One concern we have in validating these addresses is that they are commonly used during development for debug purposes. The inability to use these addresses in the test environment may hamper development efforts. In order to account for this, address validation in the example only occurs in the release build. This is accomplished by checking the current contract configuration for debug_assertions, which are disabled in a release build.

Since the test environment is not available once deployed to the production chain, we must hard code the address values into an array that can be checked during the transfer process. The zero address and empty seed address are also included as they have been an issue on other chains. ==- Solution

        // Accounts that should never receive tokens on the production chain
        // Zero, Alice, Bob, Charlie, Django, Eve, Frank and Empty Seed Address Public Key.
         fn get_default_accounts()-> [AccountId; 8] {
            let zero = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; // zero address
            let one = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]; // alice
            let two = [2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2]; // bob
            let three = [3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]; // charlie
            let four = [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4]; // django
            let five = [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5]; // eve
            let six = [6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6]; // frank
            let empty = [ 0x46, 0xEB, 0xDD, 0xEF, 0x8C, 0xD9, 0xBB, 0x16, // Empty Seed Address Public Key
                          0x7D, 0xC3, 0x08, 0x78, 0xD7, 0x11, 0x3B, 0x7E, 
                          0x16, 0x8E, 0x6F, 0x06, 0x46, 0xBE, 0xFF, 0xD7, 
                          0x7D, 0x69, 0xD3, 0x9B, 0xAD, 0x76, 0xB4, 0x7A ];

            let ret_accounts: [AccountId; 8] = [AccountId::from(zero), AccountId::from(one), AccountId::from(two), AccountId::from(three), 
                                                AccountId::from(four), AccountId::from(five), AccountId::from(six), AccountId::from(empty)];
            ret_accounts
        }

These following transfer function now performs any address validations by checking the recipient against known default addresses that would result in lost or locked tokens if they are used mistakenly on production chains.

        /// Transfers an amount of tokens to the chosen recipient.
        #[ink(message)]
        pub fn transfer(&mut self, recipient: AccountId, amount: u32) {
            let sender = self.env().caller();
            let sender_balance = self.balance_of(sender);
            if sender_balance < amount {
                return;
            }

            if cfg!(not(debug_assertions)) {
                // Only check for default accounts if this is a release build
                let default_accounts = S0token::get_default_accounts();
                if default_accounts.contains(&recipient) {
                    return;
                }
            }
            
            self.balances.insert(sender, &(sender_balance - amount));
            let recipient_balance = self.balance_of(recipient);
            self.balances.insert(recipient, &(recipient_balance + amount));
        }

Verifying Secure Solution

When developing contracts in ink!, there are a number of addresses that are used for development purposes locally and on the testnet. However, use of these addresses on the production network can result in lost gas fees and unretrievable tokens and AZERO if accidentally used within that environment. A list of these accounts can be found in the ink_env test documentation.

https://paritytech.github.io/ink/ink_env/test/struct.DefaultAccounts.html

Setup

cargo install cargo-contract --version 2.0.0-beta.1

Tests still function properly, since the zero address and test accounts are allowed in development.

cargo +nightly test -- --nocapture

Tests Output

running 4 tests
Caller: AccountId([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1])
Recipient: AccountId([2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2])
Caller: AccountId([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1])
Recipient: AccountId([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
Account Balance: 1000
test s0token::tests::total_supply_works ... ok
test s0token::tests::balance_of_works ... ok
test s0token::tests::zero_address_check ... ok
test s0token::tests::transfer_works ... ok

Build and Deploy to Testnet

Using Cargo Contract Command line

cargo +nightly contract build --release
export SEED="[put your 12 words seed phrase here]"
export URL="wss://ws.test.azero.dev"
cargo contract instantiate --suri "$SEED" --url "$URL" \
        --constructor new_token \
        --args 10000

Output

 Dry-running new_token (skip with --skip-dry-run)
    Success! Gas required estimated at Weight(ref_time: 489377768, proof_size: 0)
Confirm transaction details: (skip with --skip-confirm)
 Constructor new_token
        Args 10000
   Gas limit Weight(ref_time: 489377768, proof_size: 0)
Submit? (Y/n): 
   Code hash 0xeaae76bb62900ef5c5f17d0802b1c80a7346db0cbab00c729a74f6b1bb6e865c # Code Hash address will be different
    Contract 5GJZow5yovTExpjBEMb8ECrJJ7sutR471gvVTbCETfjcAg6e # Contract address will be different
      Events
      ....
export CONTRACT="<contract address from above>"
export ACCOUNT="<account address that instantiated the contract>"
cargo contract call --suri "$SEED" --url "$URL"  --contract "$CONTRACT"  --message balance_of --args $ACCOUNT --dry-run

Output

    Result Success!
    Reverted false
    Data Tuple(Tuple { ident: Some("Ok"), values: [UInt(10000)] })

Attempt to transfer 1000 tokens to the Zero Address

cargo contract call --suri "$SEED" --url "$URL"  --contract "$CONTRACT"  --message transfer --args 0000000000000000000000000000000000000000000000000000000000000000 1000
cargo contract call --suri "$SEED" --url "$URL"  --contract "$CONTRACT"  --message balance_of --args 0000000000000000000000000000000000000000000000000000000000000000 --dry-run   

Output showing that no tokens were assigned to the zero address

    Result Success!
    Reverted false
    Data Tuple(Tuple { ident: Some("Ok"), values: [UInt(0)] })

The contract now prevents any tokens from being transfered to the zero address account.

The same prevention will also can occur with the test accounts, such as Alice or Bob.

Attempt to transfer 250 tokens to Alice

cargo contract call --suri "$SEED" --url "$URL"  --contract "$CONTRACT"  --message transfer --args 0101010101010101010101010101010101010101010101010101010101010101 250
cargo contract call --suri "$SEED" --url "$URL"  --contract "$CONTRACT"  --message balance_of --args 0101010101010101010101010101010101010101010101010101010101010101 --dry-run

Output

    Result Success!
    Reverted false
    Data Tuple(Tuple { ident: Some("Ok"), values: [UInt(0)] })

Balance of Empty SEED Address

cargo contract call --suri "$SEED" --url "$URL"  --contract "$CONTRACT"  --message balance_of --args 5DfhGyQdFobKM8NsWvEeAKk5EQQgYe9AydgJ7rMB6E1EqRzV --dry-run

Try to transfer to the Empty SEED Address

cargo contract call --suri "$SEED" --url "$URL"  --contract "$CONTRACT"  --message transfer --args 5DfhGyQdFobKM8NsWvEeAKk5EQQgYe9AydgJ7rMB6E1EqRzV 250

Output

    Result Success!
    Reverted false
    Data Tuple(Tuple { ident: Some("Ok"), values: [UInt(0)] })

==-

Question

If you are up to the challenge, see if you know the answer to this question:

True or false: In the previous lessons of this security course, is there any lessen where the address validation checks should also have been performed?

==- Answer True. For example, in Lesson 4 the address validation should also have been performed.

Last updated