SBN

Security audit of smart contracts in TON: key mistakes and tips

Hi everyone, I’m Sergey Sobolev, a smart contract auditor and security researcher at positive.com. Our team specializes in smart contract auditing. Today, I will share the results of our team’s research and insights on auditing the security of smart contracts in FunC and Tact languages on the TON platform.

Where to start

It’s no secret that the TON blockchain differs significantly from the platforms commonly used in the industry. The first aspect I’d like to highlight is how transactions are processed in TON. All actions in the blockchain are accompanied by messages, which means that each function of your smart contract, when executed, processes a message, sends a response, or continues the chain of messages.

In TON, messages are executed asynchronously and independently of one another. This means that transactions consisting of messages exchanged between contracts can process several blocks, which leads to delays. The most unpleasant scenario is partial transaction execution, when your tokens have been debited but fail to reach the recipient due to an issue during the process. This happens because the programmer did not account for all possible scenarios or failed to add proper error handlers to the contract.

The first step in ensuring the security of a smart contract in TON is to draw all the message chains and assume that any message can fail. Then, consider: what would be the consequences of such a failure? What would cause the message to fail in the first place? What happens if there isn’t enough gas to process the entire chain? All these questions need to be answered.

For example, let’s examine the message chain diagram for the Jetton transfers, a standard token contract (TEP 74, FunC contract). In the diagram, blue circles represent contracts, white rectangles represent messages, red rectangle indicate bounced message, green rectangles indicate optional message (possible only if forward_ton_amount is not equal to zero), and yellow rectangles represent the message body with excess, which is sent only if there are TON coins left after payment.

If something happens when executing an internal_transfer message, the transfer amount will be deducted from the sender’s balance but will not be credited to the recipient. This can be called a partial fulfillment of the transaction. By using the on_bounce bounced message handler in the Jetton B wallet contract, it’s possible to return the deducted funds back to the sender.

Message flow in Jetton contracts

Now that we have a general understanding of how contracts handle messages, where exactly the messages go, and what entry points a hacker might exploit in the contract, we can move on to the code.

All input parameters should be checked carefully and thoroughly. While it’s impossible to check all data, you can catch errors during the examination of incoming messages and data. Is the authorization of incoming messages correctly configured in the contract? Sometimes, programmers simply forget to add it. Most often, they rely on the usual require, where the address is checked within a condition. The address can be calculated from the code and the data used when deploying the contract.

Jetton, for example:

cell calculate_jetton_wallet_state_init(
slice owner_address,
slice jetton_master_address,
cell jetton_wallet_code) inline {
return begin_cell()
.store_uint(0, 2)
.store_dict(jetton_wallet_code)
.store_dict(pack_jetton_wallet_data(0, owner_address, jetton_master_address, jetton_wallet_code))
.store_uint(0, 1)
.end_cell();
}

slice calculate_jetton_wallet_address(cell state_init) inline {
return begin_cell().store_uint(4, 3)
.store_int(workchain(), 8)
.store_uint(cell_hash(state_init), 256)
.end_cell()
.begin_parse();
}

The calculate_jetton_wallet_state_init function generates the initial state of the contract, and the calculate_jetton_wallet_address function calculates the address of the contract through hashing the initial state, which is obtained from the code in jetton_wallet_code and the variables packed into the cell via pack_jetton_wallet_data.

In Tact, things are much simpler. The example I took from here shows that the address calculation is performed in one line:

receive(msg: HiFromChild) {
let expectedAddress: Address =
contractAddress(initOf TodoChild(myAddress(), msg.fromSeqno));
require(sender() == expectedAddress, "Access denied");
// only the real children can get here
}

In addition, authorization requirements must not negatively impact the execution of smart contracts due to excessive centralization; this should be accounted for in the design of the business model from the very beginning. What measures does this model use to prevent contracts from being frozen or deleted?

One should pay attention to the processing of external messages (coming from the Internet) by the recv_external function in FunC smart contracts. It’s important to check whether the accept_message() function is applied only after all proper checks. This precaution helps prevent gas-sucking attacks, since once accept_message() is called, the contract pays for all further operations. External messages have no context (e.g., sender or value), 10,000 units of gas credit are given for processing, which is enough to verify the signature and accept the message. Of course, it all depends on the design of the contract, but if possible, it makes sense to write a contract in a way that avoids accepting external messages. The recv_external function is one of the input points that should be checked several times.

The asynchronous nature of the TON blockchain

After thoroughly analyzing the code, it’s helpful to revisit the diagrams and walk through them again, keeping in mind a few TON postulates:

  1. Messages are guaranteed to be delivered, but the delivery time is not predictable.
  2. The order of messages can only be predicted if messages are sent from one contract to another, in which case logical time is used.
  3. If multiple messages are sent to different contracts, the order in which they are received cannot be guaranteed.

The figures below illustrate the message handling process very clearly.

Each message is assigned its own logical time. A message with a lower logical time will be processed earlier, so we can rely on the sequence of processing. However, if there are multiple contracts, it’s not possible to determine which message will be received first.

Suppose we have three contracts: A, B, and C. In a transaction, contract A sends two internal messages — msg1 and msg2 — one to contract B and the other to contract C. Even if they were created in the exact order (msg1 first, then msg2), we can’t be sure that msg1 will be processed before msg2. For clarity, the documentation assumes that contracts send back messages msg1' and msg2' after msg1 and msg2 have been executed by contracts B and C. This would result in two transactions going to contract A — tx2' and tx1'. As a result, there would be two possible outcomes:

  1. tx1'_lt < tx2'_lt
  2. tx2'_lt < tx1'_lt
Options for handling messages between contracts A, B, and C

The reverse works exactly the same way when two contracts B and C send messages to contract A. Even if the message from B was sent earlier than the one from C, it is impossible to determine which one will be delivered first. In any scenario with more than two contracts, the order of message delivery can be arbitrary.

Undefined order of message delivery from B and C to A

When examining message flow diagrams, we need to answer the following questions:

  1. What happens if another process is running in parallel?
  2. How might this affect the contract and how can this be mitigated?
  3. Can any required values change while the message chain is executing?
  4. What parameters or states of other contracts does this contract depend on?
  5. How much do the operations depend on the sequence of message arrivals?

You should always expect intermediaries to appear during message processing. That is, if a property of a contract was checked at the beginning, you should not assume that it will still be checked by this property at the third stage. For the most part, the carry-value pattern protects against these issues. Is it being used appropriately to manage state between messages?

Common errors in TON

Let’s begin with the most obvious: never send private data (passwords, keys, etc.) to the blockchain. Blockchains are public, and any data stored on them can be compromised.

Another common issue is the processing of bounced messages. For example, in the case of Jettons, failing to handle bounced messages could result in tokens being sent into the void. In the TON Stablecoin project, there is a mechanism to process bounced op::internal_transfer messages, which are sent to Jetton-wallet when tokens are minted. Without such processing, the total_supply value will increase and would be irrelevant when minting, since the tokens would not arrive in the wallet and could not be in circulation.

Errors in formulas, algorithms, and data structures are common. For example, performing division before multiplication can cause a loss of accuracy, leading to rounding errors:

let x: Int = 40;
let y: Int = 20;
let z: Int = 100;
// 40 / 100 * 20 = 0
let result: Int = x / z * y;
// 40 * 20 / 100 = 8
let result: Int = a * c / b;

The code may also contain common errors, such as:

  1. Duplicate code
  2. Unreachable code
  3. Inefficient algorithms
  4. Poorly ordered expressions in conditional statements
  5. Logical errors

Errors in data parsing

A replay attack is possible in TON. This happens because TON does not use one-time numbers for the address (like nonce in Ethereum) to ensure unique signatures. This feature is implemented in the standard wallets used to store and transfer TON. In these wallets, the wallet contract receives an external message with a verified signature, the sent seqno (similar to nonce in Ethereum) stored in the contract storage is also verified. Below is a list of the checks that a standard wallet performs before accepting a message:

  throw_unless(33, msg_seqno == stored_seqno);
throw_unless(34, subwallet_id == stored_subwallet);
throw_unless(35, check_signature(slice_hash(in_msg), signature, public_key));
accept_message();

It is good practice to follow the carry-value pattern, which implies that a value is being passed, not a message.

For example, in the case of Jetton:

  1. The sender subtracts the amount from their balance and sends it with op::internal_transfer.
  2. The recipient accepts the amount via message and adds it to their own balance (or rejects it).

In TON, it’s impossible to get actual data through a request, because by the time the response reaches the requester, the data may no longer be up to date. Therefore, in Jetton it’s impossible to get the onchain balance, because while the response is on its way, the balance may have already been spent by someone else.

Alternate option:

  1. The sender requests the balance of the Jetton wallet through the master contract.
  2. The wallet resets the balance and sends it to the master contract.
  3. The master contract, having received the funds, determines if they are sufficient and either uses them (by sending them somewhere) or returns them to the sender’s wallet.

This is roughly how you can get a balance. The same approach can be applied to all other data.

Keep an eye on how reads and writes are performed in cells, because inattention can lead to errors. For example, an overflow problem occurs when a user tries to store more data in a cell than it supports. The current limit is 1023 bits and 4 references to other cells. When these limits are exceeded, the contract returns an error with exit code 8 during the compute phase.

The underflow problem occurs when a user tries to get more data from a structure than it supports. When this happens, the contract returns an error with exit code 9 during the compute phase.

You can read about exit codes here.

// storeRef is used more than 4 times
beginCell()
.storeRef(...)
.storeAddress(myAddress())
.storeRef(...)
.storeRef(...)
.storeRef(...)
.storeRef(...)
.endCell()

Random number generation in TON

As in EVM-like blockchains, validators can affect randomness, and hackers can calculate the formula for generating it. So you need to be smart about the code that requires it.

FunC has a random() function that cannot be used without additional functions. To add unpredictability to number generation, you can use randomize_lt(), which will add the current logical time to the initial value, causing different transactions to have different results. Alternatively, you can use randomize(x), where x is a 256-bit integer, essentially a hash of any data.

Using nativeRandom and nativeRandomInterval in Tact is not a good idea because they do not initialize the random number generator with nativePrepareRandom in advance. Tact uses randomIntor random respectively.

Problems with sending messages

Each contract receives messages and either continues the sending chain or responds to them. One must be sure that the message is formed correctly, i.e., all keys and magic numbers (flags, operating modes, and other parameters) correspond to the contract logic and do not lead to excessive depletion of its balance when forming the message. This is especially important with regard to storage fees: in TON, gas must be paid for every second that the smart contract is stored on the blockchain. However, it is not necessary to accumulate all unspent gas at the contract address. Instead, it’s better to properly design the contract logic so that any excess gas is returned to the sender when necessary. Keep in mind that running out of gas leads to partial execution of transactions, which can cause critical problems.

For example, if you remove the bounced message handler in the Jetton wallet contract, then in case of an error during token transfer (e.g., an exception occurred or gas ran out), this and subsequent steps will not be executed, and the deducted tokens will not be recovered — they will simply be burned. The transaction will only be partially executed. The same issue applies to the master contract in TON Stablecoin. During token minting, total_supply increases. However, if the message bounces and there is no handler, the total_supply value will be incorrect, because extra tokens that are not in circulation will be taken into account:

    if (msg_flags & 1) { 
in_msg_body~skip_bounced_prefix();
;; only bounced mint messages are processed
ifnot (in_msg_body~load_op() == op::internal_transfer) {
return ();
}
in_msg_body~skip_query_id();
int jetton_amount = in_msg_body~load_coins();
(int total_supply, slice admin_address, slice next_admin_address, cell jetton_wallet_code, cell metadata_uri) = load_data();
;; this subtracts the amount of the transfer from the total supply.
save_data(total_supply - jetton_amount, admin_address, next_admin_address, jetton_wallet_code, metadata_uri);
return ();
}

The documentation is the best resource for understanding how messages are generated. There may be cases where message modes are formed. It’s always a good idea to verify that the programmer has specified the bitwise OR operator accurately.

For example, in Tact:

// The flag is duplicated
send(SendParameters{
to: recipient,
value: amount,
mode: SendRemainingBalance | SendRemainingBalance
});

A dangerous practice is sending messages from a loop:

  1. A loop may be infinite or have too many iterations, which can lead to unpredictable contract behavior.
  2. Continuous looping without termination can result in an out-of-gas attack.
  3. Attackers can use unlimited cycles to perform denial-of-service attacks.

Special attention should be given to the function responsible for sending messages. In the standard Tact library, there is a function called nativeSendMessage, which is a low-level equivalent of the send function. When using nativeSendMessage, one can make a mistake when forming a message. Therefore, it’s better to use this function only if the contract has complex logic that cannot be expressed in any other way.

Management of data storage

The TON blockchain does not support infinite data structures. In Solidity, there is the ERC-20 standard, which uses a single contract with address → balance mapping. In TON, the equivalent of ERC-20 is the Jetton standard, which is implemented as a system of contracts. Jetton consists of two contracts: jetton-minter.fc and jetton-wallet.fc. For each address, a wallet contract is created with the ability to send and receive tokens. The minter contract serves as the main contract, storing metadata and providing the ability to mint or burn tokens.

Jetton contract interactions

This scheme is dictated by the storage device in the TON blockchain, which is a cell tree. A cell tree does not allow mappings with complexity O(1), and it turns out that the size of the mapping affects the amount of gas spent: the larger the mapping, the more gas is needed to search for it. Therefore, you should evaluate all mappings and check if there is a way to remove data from them (e.g., via .del). Without a mechanism to clean or delete records, uncontrolled storage growth can occur.

So, is it just about avoiding bloating the contract storage? Not exactly. This information is more relevant for FunC than Tact, since in FunC the developer manually manages the storage. It’s crucial to check if the order of variables is correct when packing them into the repository. If a variable ends up in the position of another variable, the logic of the contract could break.. Another scenario is also possible: state variables may be overwritten due to variable name collisions or namespace contamination.

Almost all messages are handled using the following pattern:

() function(...) impure {
(int var1, var2, <a lot of vars>) = load_data();
... ;; handler logic
save_data(var1, var2, <a lot of vars>);
}

Unfortunately, there is a tendency for <a lot of vars> to be a simple enumeration of all contract data fields, which can result in a large number of fields in a line, which can be confusing. Therefore, when adding a new field to the repository, all the load_data() and save_data() calls will need to be updated, making it a time-consuming task. The result may be as follows:

save_data(var2, var1, <a lot of vars>);

In addition, one should not neglect a property of FunC known as variable shading. In general, this occurs when a variable in the internal scope is declared with the same name as a variable in the external scope that already exists. Accordingly, there is a possibility that a local variable will get into the repository. This can happen due to the re-declaration of variables in FunC:

int x = 2; 
int y = x + 1;
int x = 3; ;; is equivalent to assigning x = 3

When considering efficiency, nested storage is a good practice. However, there is also a high probability of errors dealing with it. Nested storage refers to variables contained within other variables, which are only unpacked when needed rather than every time messages are processed:

()function(...) impure { 
(slice mint, cell burn, cell swap) = load_data();
(int total_supply, int amount) = mint.parse_mint_data();

mint = pack_mint_data(total_supply + value, amount);
save_data(mint, burn, swap);
}

Use end_parse() when parsing a repository or message payload wherever possible to ensure that the data is processed correctly, so you can ensure that no unread data remains. In Tact, the endParse function returns an exception with code 9 (cell underflow), unlike empty, which returns true or false.

In Tact, it’s possible to add an optional variable value to the contract store, that is, a special value of null. If a developer creates an optional variable or field, they should use this functionality by referring to the null value somewhere in the code. Otherwise, the optional type should be removed to simplify and optimize the code:

contract Simple {
a: Int?;
get fun getA(): Int {
return self.a!!;
}
}

Problems with code update

Code updating is one of the most convenient features of the platform. However, while it adds the ability to update the source code, it can also negatively affect the decentralization of the protocol. Imagine if the protocol creators tamper with the contract code or if someone hacks their multi-signature and changes the protocol. Importantly, updating the code does not affect the current transaction. The changes only take effect after successful execution.

The functions set_code and set_data are used to update registers c3 and c4 respectively. The set_data function completely overwrites the register. Before performing an update in FunC, one should ensure the code does not violate the existing data storage logic. Watch out for potential storage collisions and variable packing.

At the time of writing, Tact does not provide a way to upgrade contracts, but you can use trait Upgradable from the Ton-Dynasty library fork (analogous to the OpenZeppelin library for Solidity). Functions from FunC are used there, but you should definitely take care of storage migration if a new variable is added.

General points on safe development and auditing

  1. To avoid confusion with flags and message modes, add constants — wrappers for numeric literals. This makes the code clearer and more readable.
  2. Ensure that all bounced messages are being processed.
  3. Carefully calculate gas costs and verify that there is enough gas to run and store the contract on the blockchain.
  4. Be careful with data structures that can grow indefinitely, as they increase gas costs over time.
  5. Check to ensure variables are not declared or initialized twice.
  6. Keep the contract logic self-contained and avoid including untrusted external code. Example from documentation.
  7. To save gas, pay attention to the order of logical expressions in if or require; placing constant or cheaper conditions first can prevent unnecessary execution of expensive operations.
  8. A lot of errors occur in data processing, i.e., in formulas and algorithms, so double-check all calculations.
  9. Look for logical loopholes that could be exploited.
  10. Assess the possibility of replay attacks.
  11. Use unique prefixes or modules to prevent variable name collisions.
  12. Ensure the contract has clear and detailed documentation of its functionality and design solutions.
  13. Have the contract code reviewed by independent auditors to identify potential problems.
  14. Ensure the contract meets TON’s standards and best practices.

By following this checklist, you can systematically assess the security and reliability of TON smart contracts, identifying potential vulnerabilities, and ensure reliable operation in the TON ecosystem.

Our team audits smart contracts of different blockchain platforms. Contact us via email: [email protected].


Security audit of smart contracts in TON: key mistakes and tips was originally published in Positive Web3 on Medium, where people are continuing the conversation by highlighting and responding to this story.

*** This is a Security Bloggers Network syndicated blog from Positive Web3 - Medium authored by Sergey Sobolev. Read the original post at: https://blog.positive.com/security-audit-of-smart-contracts-in-ton-key-mistakes-and-tips-33ff3502cfd7?source=rss----820ff037acec---4