- Security
- A
Audit of smart contract security in TON: key mistakes and tips
Hello everyone! Sergey Sobolev is here, a specialist in distributed systems security at Positive Technologies, our team is engaged in auditing smart contracts. Today I will tell you about the results of our team's research and conclusions regarding the security audit of smart contracts in the FunC and Tact languages of the TON platform.
Where to start
It is no secret that the TON blockchain has significant differences from the usual industry platforms. The first thing I want to mention is the way transactions are processed in TON. All actions in the blockchain are accompanied by messages, that is, in fact, it turns out that each function of your smart contract will process some message and send a response or continue the chain of messages.
At the same time, messages are executed asynchronously and independently of each other, due to which transactions consisting of messages between contracts can process several blocks, which leads to delays. And the most unpleasant thing is the partial execution of the transaction, when your tokens were debited, but did not reach the recipient, because something happened while they were on the way, and the programmer is to blame for everything, who did not keep track of all possible options and did not add error handlers to the contract.
So, the first thing to do when you think about the security of a smart contract in TON is to draw all the chains of messages and assume that each message can fail. Then what will be the consequences? And why can they fail at all? What if there is not enough gas to process the entire chain? All these questions need to be answered.
For example, you can take the message chain scheme for translating Jetton, the standard token contract (TEP 74, FunC contract). In the scheme, blue circles are contracts, white rectangles are messages. The red rectangle highlights the bounced message, the green one is an optional message, possible only if forward_ton_amount
is not zero, the yellow one is the message body with excess, which is sent only if there are TON coins left after payment.
If something happens during the execution of the 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 partial transaction execution. Using the bounced message handler on_bounce
in the Jetton B wallet contract, the deducted funds can be returned back.
Now that we have a general picture of how contracts handle messages, where exactly the messages are directed, and what entry points for a hacker are in the contract, we can delve into the code.
Carefully and closely all input parameters need to be checked, it is impossible to check all data, but errors can be caught at the stage of investigating the incoming message and data. Is the authorization of incoming messages in the contract configured correctly? Sometimes programmers just forget to add it. Most often, a regular require
is used, where the address is checked in the condition, it, in turn, can be calculated from the code and data that are used when deploying the contract.
For example, in Jetton:
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 function calculate_jetton_wallet_state_init
forms the initial state of the contract, and the function calculate_jetton_wallet_address
calculates the contract address by hashing the initial state, which is obtained from the code in jetton_wallet_code
and the variables packed into the cell through pack_jetton_wallet_data
.
In Tact, everything is much simpler. In the example I took from here, it is clear that the address calculation is done 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, it is important to ensure that authorization requirements do not negatively impact the execution of smart contracts due to excessive centralization; all of this should be initially embedded in the business model design. What measures does this model use to prevent freezing or deleting contracts?
Attention should be paid to the processing of external messages (coming from the internet) by the function recv_external
(in smart contracts written in FunC): is the function accept_message()
applied only after all proper checks. This is necessary to prevent gas-draining attacks, as after calling accept_message()
, the contract pays for all further operations. External messages have no context (e.g., sender
, value
), and 10,000 gas units are given on credit for processing, which is enough to verify the signature and accept the message. Of course, it all depends on the contract design, but if possible, it makes sense to write the contract without the ability to accept external messages. The function recv_external
is one of the entry points that needs to be checked several times.
The asynchronous nature of the TON blockchain
After examining all the code, you can return to the diagrams and go through them again, recalling a few postulates of TON:
Messages are guaranteed to be delivered, but not at predictable times.
The order of messages can only be predicted if the messages are sent from one contract to another contract, in which case logical time is used.
If multiple messages are sent to different contracts, the order of their receipt is not guaranteed.
The figures below clearly explain how to work with messages.
Each message is assigned its own logical time, the message with the smaller logical time will be processed first, so you can rely on the processing sequence, however, if there are several contracts, it is not known which message will be received first.
Suppose we have three contracts — A
, B
, and C
. In the transaction, contract A
sends two internal messages — msg1
and msg2
, one to contract B
, the other to C
. Even if they were created in the exact order (msg1
, then msg2
), we cannot be sure that msg1
will be processed before msg2
. For clarity, the documentation assumes that the contracts send back messages msg1'
and msg2'
after msg1
and msg2
have been executed by contracts B
and C
. As a result, there will be two transactions to contract A
— tx2'
and tx1'
, so there are two possible options:
tx1'_lt < tx2'_lt
tx2'_lt < tx1'_lt
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 from C
, it is not known which one will be delivered first. In any scenario with more than two contracts, the order of message delivery can be arbitrary.
So, by examining the message flow diagrams, you need to answer the following questions:
What will happen if another process is running in parallel?
How can this affect the contract and how can it be mitigated?
Can any required values change while the message chain is executing?
On which parameters or states of other contracts does this contract depend?
How dependent are the operations on the sequence of incoming messages?
You should always expect intermediaries to appear during message processing. That is, if some property of the contract was checked at the beginning, do not assume that it will still pass the check for this property at the third stage. For the most part, the carry-value pattern protects against all this. Is it used properly to manage state between messages?
Common mistakes in TON
Perhaps we can start with the most obvious: do not send private data to the blockchain (passwords, keys, etc.). The blockchain is public, and all data will be accessible.
Do not forget about handling bounced messages; if there was no such handling in Jetton, tokens could be sent into the void. For example, in the TON Stablecoin project, there is handling of a bounced message op::internal_transfer
, which is sent to the Jetton wallet when minting tokens. If there is no handling, then when minting, the total_supply
will increase and be irrelevant, as the tokens did not reach the wallet and cannot be in circulation.
Errors in formulas, algorithms, and data structures are common. For example, if you perform division first and then multiplication, you can lose calculation accuracy and get a rounding error:
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;
In addition, there can be the most standard errors in the code, including:
Code duplication.
Unreachable code.
Inefficient algorithms.
Poor order of expressions in conditional statements.
Logical errors.
Errors in data parsing.
In TON, a replay attack is possible. This occurs because TON lacks the concept of one-time numbers for addresses (like nonce
in Ethereum), which allow for unique signatures. This concept is added to the standard wallets we use for storing and transferring TON. That is, an external message with a signature arrives in the wallet contract, which is verified, and the sent seqno
(analogous to nonce
in Ethereum), stored in the contract storage, is also verified. Below is the listing of checks after which the standard wallet accepts the 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();
A good practice is to follow the carry-value pattern, which implies that the value is transferred, not the message.
For example, in Jetton:
The sender deducts the
amount
from their balance and sends it withop::internal_transfer
.The recipient accepts the
amount
through the message and adds it to their balance (or rejects it).
That is, in TON, it is impossible to get up-to-date data through a request, because by the time the response reaches the requester, the data may already be outdated. Therefore, in Jetton, it is impossible to get the on-chain balance, because while the response is being received, the balance may already be spent by someone else.
Alternative option:
The sender requests the Jetton wallet balance through the master contract.
The wallet resets the balance and sends it to the master contract.
The master contract, having received the funds, decides whether they are sufficient and either uses them (sends them somewhere) or returns them to the sender's wallet.
This is roughly how you can get the balance. A similar scheme can be applied to all other data.
Watch how reading and writing to cells occurs, because inattention can lead to an error. The 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. If these limits are exceeded, the contract returns an error with exit code 8 during the computation phase.
The underflow problem occurs when a user tries to retrieve more data from a structure than it supports. When this happens, the contract returns an error with exit code 9 during the computation 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 influence randomness, and hackers can calculate the randomness generation formula. Therefore, you need to approach the code wisely that requires it.
In FunC, there is a random()
function that cannot be used without additional functions. To add unpredictability when generating a number, you can use the randomize_lt()
function, which will add the current logical time to the initial value, resulting in different transactions having different results. In addition, you can use randomize(x)
, where x
is a 256-bit integer, simply put, a hash of some data.
Using nativeRandom
and nativeRandomInterval
in Tact is not the best idea, as they do not initialize the random number generator with nativePrepareRandom
in advance. In Tact, randomInt
or random
is used accordingly.
Problems with sending messages
Each contract accepts messages and either continues the sending chain or responds to them. You need to be sure that the message is formed correctly, that is, all keys and magic numbers (flags, operating modes, and other parameters) when forming the message correspond to the logic of the contract and do not lead to excessive depletion of its balance. This is especially important regarding storage payments: in TON, you need to pay gas for every second while the smart contract is stored in the blockchain. However, you do not need to accumulate all the unspent gas at the contract address, it is better to properly think through the logic of returning the excess to the sender when necessary. It is important to remember that gas exhaustion leads to partial execution of transactions, which can cause critically dangerous problems.
For example, if you remove the bounced message handler in the Jetton wallet contract, then in case of an error during the token transfer (an exception occurred), this and subsequent steps will not be executed and the debited tokens cannot be restored, they will simply burn. The transaction will be partially executed. The same can be said about the master contract in TON Stablecoin. During token minting, total_supply
increases, but if the message bounces and there is no handler, then total_supply
will be incorrect, as extra tokens that are not in circulation will be accounted for:
if (msg_flags & 1) {
in_msg_body~skip_bounced_prefix();
;; only mint message bounces 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();
;; here the transfer amount is subtracted from the total supply
save_data(total_supply - jetton_amount, admin_address, next_admin_address, jetton_wallet_code, metadata_uri);
return ();
}
The best way to understand the ways of forming messages is to refer to the documentation. There may be cases of incorrect message mode formation. And just in case, you need to check if the programmer has correctly specified the bitwise OR
operator.
For example, in Tact:
// The flag is duplicated
send(SendParameters{
to: recipient,
value: amount,
mode: SendRemainingBalance | SendRemainingBalance
});
Sending messages from a loop can be a dangerous practice:
The loop may turn out to be infinite or have too many iterations, which can lead to unpredictable contract behavior.
Continuous looping without termination can lead to an out-of-gas attack.
Attackers can use unlimited loops to create denial-of-service attacks.
Special attention should be paid to the message sending function. The Tact standard library has a function nativeSendMessage
, which is a low-level analogue of the send
function. Using nativeSendMessage
when forming a message can lead to errors, so it is better to use it only if the contract has complex logic that cannot be expressed in another way.
Data Storage Management
The TON blockchain does not favor infinite data structures. In Solidity, there is the ERC-20 standard, which is a single contract with a mapping of address → balance. In TON, the analogue of ERC-20 is Jetton, and as a result, it is an entire system of contracts. The Jetton standard consists of two contracts — jetton-minter.fc and jetton-wallet.fc. For each address, its own wallet contract is created with the ability to send and receive tokens, and the minter contract is the main contract with metadata and allows you to mint or burn tokens.
This scheme is due to the structure of the storage in the TON blockchain, which is a tree of cells. The cell tree does not allow creating mappings with complexity O(1)
, and it turns out that the size of the mapping affects the amount of gas spent: the larger it is, the more gas is needed for the search. Therefore, all mappings should be evaluated and checked for the possibility of data deletion from it (for example, through .del
). The lack of a way to clear or delete records can lead to uncontrolled storage growth.
So, do we not inflate the contract storage and that's it? No, this information is more for FunC than Tact, as in FunC the developer manually manages the storage. It is necessary to check the correctness of the order of variables when packing into storage. It may happen that some variable ends up in the place of another, then the logic of the contract will be violated. Another scenario is possible: state variables may be overwritten due to variable name collisions or namespace pollution.
Almost all messages are processed according to this template:
() function(...) impure {
(int var1, var2, ) = load_data();
... ;; handler logic
save_data(var1, var2, );
}
Unfortunately, there is a tendency when is a simple enumeration of all contract data fields, and there can be a lot of them. Therefore, when adding a new field to the storage, it will be necessary to update all calls to
load_data()
and save_data()
, which can be a laborious task. And in the end, it may turn out like this:
save_data(var2, var1, );
In addition, one should not neglect such a property of FunC as variable shadowing. In a general sense, this is possible when a variable in the inner scope is declared with the same name as an existing variable in the outer scope. Accordingly, there is a possibility that the local variable will end up in the storage, this is possible due to the redeclaration of variables in FunC:
int x = 2;
int y = x + 1;
int x = 3; ;; equivalent to assigning x = 3
If you think about efficiency, a good practice would be nested storage, but working with it also has a high probability of error. Nested storage is variables within variables that are unpacked only when needed, not 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 storage or message payloads wherever possible to ensure data processing correctness, so you can make sure there is no unread data left. In Tact, the endParse
function throws an exception with code 9 (cell underflow), unlike empty
, which returns true
or false
.
In Tact, it is possible to add an optional variable value to the contract storage, that is, a special null
value is used. If a developer creates an optional variable or field, they must use its functionality by referencing 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 updates
This is one of the most convenient features of the platform, however, the source code must include the ability to update, but it can negatively affect the decentralization of the protocol. Imagine that the protocol creators once replace the code-contract or someone hacks their multisig and changes the protocol. It is important that the code update does not affect the current transaction, changes take effect only after successful completion of execution.
The set_code
and set_data
functions are used to update registers с3
and с4
respectively, set_data
completely overwrites the register. Before updating in FunC, you need to check if the code violates the existing data storage logic — watch for storage collisions and variable packing.
In Tact at the time of writing this article there is no possibility to upgrade contracts, however, for this, you can use trait Upgradable
from the fork of the Ton-Dynasty library (an analogue of the OpenZeppelin library for Solidity). Functions from FunC are used there, however, it is necessary to take care of storage migration if a new variable is added.
General theses on secure development and auditing
To avoid confusion with flags and message modes, add constants - wrappers for numeric literals. This will make the code clearer and more readable.
Ensure that all bounced messages are handled.
Carefully calculate gas costs and check if there is enough gas to operate and store the contract on the blockchain.
Be careful with data structures that can grow indefinitely, as they increase gas costs over time.
Check if variables are declared or initialized twice.
Keep the contract logic autonomous and avoid including untrusted external code (Example from the documentation):
a) Executing third-party code is unsafe as out of gas exceptions cannot be caught using
CATCH
.b) An attacker can use
COMMIT
to change the contract state before raising an out of gas exception.To save gas, pay attention to the order of logical expressions in
if
orrequire
, placing constant or cheaper conditions first can prevent unnecessary execution of costly operations.A lot of errors are made when processing data, i.e., in formulas and algorithms, so everything needs to be checked.
Look for logical loopholes that can be exploited.
Evaluate the possibility of replay attacks.
Use unique prefixes or modules to prevent variable name collisions.
Provide clear and detailed documentation on the functionality and design decisions of the contract.
Have the contract code reviewed by independent auditors to identify potential issues.
Ensure that the contract complies with TON standards and best practices.
By following this checklist, you can systematically assess the security and reliability of TON smart contracts, identifying potential vulnerabilities and ensuring reliable operation in the TON ecosystem.
Write comment