Smart Contracts

A few small bits about attacking Smart Contracts in Web3

Compiling .sol to .abi

A contract's source code is found in .sol files, but code often requires a general structure instead, which is the Application Binary Interface (ABI) format, simply encoded in JSON. You can compile Solidity code into .abi files that you can use in your attacking script to interact with the contract in a known way.

You can compile a single file to ABI into the current directory using the following command:

$ npx solc --abi <NAME>.sol -o <DIRECTORY>
# # For example
$ npx solc --abi Setup.sol -o .

It might complain about having the wrong compiler version installed, but this can often be circumvented by changing the version in the contract source itself. You might get a ParserError like this:

Setup.sol:1:1: ParserError: Source file requires different compiler version (current compiler is 0.7.3+commit.9bfce1f6.Emscripten.clang) - note that nightly builds are considered to be strictly less than the released version

It says the current version is 0.7.3, so simply change that first line in the source:

- pragma solidity ^0.8.18;
+ pragma solidity ^0.7.3;

Simple Interaction

Take the following contract as a simple example:

pragma solidity ^0.8.18;

contract Example {
    bool public updated;

    function call_me(uint256 number) external {
        if (number == 42) {
            updated = true;
        }
    }
}

To interact with a smart contract on a private chain, you need the following:

Then you can use libraries like web3.py or web3.js to do the heavy lifting. The libraries are very similar in usage, but in the following examples, I will use the Python version.

It starts with connecting to the RPC provider, and creating an account object from your private key:

from web3 import Web3

# Connect to the private chain using an RPC provider
web3 = Web3(Web3.HTTPProvider('http://<HOST>:<PORT>'))

# Set the account that will execute transactions
private_key = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'
account = web3.eth.account.privateKeyToAccount(private_key)

From here, you likely want to interact with a contract. You can get an instance of the contract in Python by opening the .abi file (see Compiling .sol to .abi) and providing the address of the contract on the server.

# Create an instance of the contract
contract_address = '0x9876543210abcdef0123456789ABCDEF01234567'
contract_abi = open('Example.abi').read()
example = web3.eth.contract(address=contract_address, abi=contract_abi)

Now that we have an instance of the contract, we can interact with it by calling functions on it. In the example Solidity code above, we need to call the call_me() function with an argument of 42. In our script that would look like this:

tx_hash = example.functions.call_me(42).transact()
print(tx_hash)  # b'\x91\xfb\x10\x93...

If you run this script and the tx_hash prints something, it probably worked. Otherwise, you will likely receive a clear Exception on why it did not work.

Manual Transactions

These function calls abstract away a lot of details, but sometimes we as the attacker want more low-level control over the transaction being sent. Here are two examples.

The fallback() method

The contract might contain a payable method named fallback():

fallback() external payable {
    ...
}

You cannot call this function directly, because it has a special meaning. This function is called when the function you try to call does not exist. It is often used for updated contracts that need to handle the case when scripts interacting with it don't update. But to intentionally call this function we would need to try and call a wrong function name in our script.

Web3 won't actually let you do this straight away, to help you not make mistakes. But in the case where you intentionally want to do this, you can trick it into thinking the wrong method does exist.

tx_hash = example.functions.wrong().transact()
print(tx_hash)
Error before transaction
web3.exceptions.ABIFunctionNotFound: ("The function 'wrong' was not found in this contract's abi. ", 'Are you sure you provided the correct contract abi?')

To bypass this, we can just manually change the .abi file to add a function called wrong, and make it think it exists:

  {
    "inputs": [],
    "name": "wrong",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },

When we now run the script again, it will correctly pass it through to the fallback() method.

The receive() method

Your contract may also have receive() method:

receive() external payable {
    ...
}

This method is used when you aren't calling a function at all, so when there is no data involved in your transaction, specifically a transaction directly to the contract address. To trigger this function we just have to manually create a transaction and send it over, without the data: component. Here is an example:

transaction = {
    'from': account.address,
    'to': contract_address,
    'value': web3.toWei(0, 'ether'),
    'gas': 2000000,
    'gasPrice': web3.toWei('50', 'gwei'),
}

tx_hash = web3.eth.send_transaction(transaction)
print(tx_hash)

In this case, we send 0 ether with some gas price configuration. It is sent to the contract address which will trigger the receive() method.

As I hinted, you can add a data component to this transaction which calling functions will automatically do for you. Here you can manually craft any call you want to make.

Requirements

With more involved contracts, you'll likely find the modifier keyword and the require() function. These can set specific conditions for if you can call a method or not. If this condition fails, your call will not go through. For example:

contract ShootingArea {
    bool public allowed;
    bool public updated;

    modifier isAllowed() {
        require(allowed);
        _;
    }
    
    function open_gates() public {
        allowed = true;
    }

    function enter() public isAllowed {
        updated = true;
    }
}

Here, someone would first have to call open_gates() before they could call enter(). Keep in mind that this state is remembered, so if you set allowed = true it will now forever be true, and you will be allowed in the next call.

Last updated