TON smart contract security best practices
This comprehensive guide covers the most critical security vulnerabilities found in TON smart contracts, based on real-world audits and security research. Understanding these pitfalls is essential for developing secure smart contracts on TON Blockchain.
Many of these vulnerabilities can lead to complete loss of funds. Always conduct thorough security audits before deploying contracts to mainnet.
Critical
Missing impure modifier
Severity: 🔴 Critical
The absence of the impure
modifier allows the compiler to skip function calls if the return value is unused, potentially bypassing critical security checks.
Vulnerable code:
() authorize(sender) inline {
throw_unless(187, equal_slice_bits(sender, addr1) | equal_slice_bits(sender, addr2));
}
Secure implementation:
() authorize(sender) impure inline {
throw_unless(187, equal_slice_bits(sender, addr1) | equal_slice_bits(sender, addr2));
}
Always add the impure
modifier to functions that perform state changes or critical validations.
Incorrect use of modifying/non-modifying methods
Severity: 🔴 Critical
Using .
instead of ~
for modifying methods means the original data structure remains unchanged, leading to logic errors.
Vulnerable code:
(_, slice old_balance_slice, int found?) = accounts.udict_delete_get?(256, sender);
Secure implementation:
(_, int found?) = accounts~udict_delete_get?(256, sender);
if(found?) {
;; accounts dictionary has been modified
}
- Non-modifying (
.
): Returns modified copy, original unchanged - Modifying (
~
): Modifies the original variable in place
Signed/unsigned integer vulnerabilities
Severity: 🔴 Critical
Improper handling of signed integers can allow attackers to exploit overflow/underflow conditions.
Vulnerable code:
(cell,()) transfer_voting_power(cell votes, slice from, slice to, int amount) impure {
int from_votes = get_voting_power(votes, from);
int to_votes = get_voting_power(votes, to);
from_votes -= amount; // Can become negative!
to_votes += amount;
votes~set_voting_power(from, from_votes);
votes~set_voting_power(to, to_votes);
return (votes,());
}
Secure implementation:
(cell,()) transfer_voting_power(cell votes, slice from, slice to, int amount) impure {
int from_votes = get_voting_power(votes, from);
int to_votes = get_voting_power(votes, to);
throw_unless(998, from_votes >= amount); // Validate sufficient balance
from_votes -= amount;
to_votes += amount;
votes~set_voting_power(from, from_votes);
votes~set_voting_power(to, to_votes);
return (votes,());
}
Insecure random number generation
Severity: 🔴 Critical
Using predictable sources like logical time for randomness allows attackers to predict and exploit outcomes.
Vulnerable code:
int seed = cur_lt(); // Predictable!
int seed_size = min(in_msg_body.slice_bits(), 128);
if(in_msg_body.slice_bits() > 0) {
seed += in_msg_body~load_uint(seed_size);
}
set_seed(seed);
if(rand(10000) == 7777) {
;; Attacker can predict this
}
Never rely on on-chain randomness for critical operations. Validators can influence or predict random values. Consider using commit-reveal schemes or external oracles for true randomness.
Missing bounced message handling
Severity: 🔴 Critical
Failing to handle bounced messages can lead to inconsistent state and fund loss.
Secure implementation:
() recv_internal(int msg_value, cell in_msg_full, slice in_msg_body) impure {
slice in_msg_full_slice = in_msg_full.begin_parse();
int msg_flags = in_msg_full_slice~load_msg_flags();
if (msg_flags & 1) { // Check bounced flag
on_bounce(in_msg_body);
return ();
}
;; Normal message processing
}
() on_bounce(slice in_msg_body) impure {
in_msg_body~skip_bits(32); // Skip 0xFFFFFFFF
int op = in_msg_body~load_op();
;; Handle specific bounced operations
if (op == op::transfer) {
;; Restore user balance
}
}
Sending private data on-chain
Severity: 🔴 Critical
All data stored on blockchain is public and permanent, including transaction history.
Vulnerable approach:
;; DON'T: Storing password hash or private data
cell private_data = begin_cell()
.store_slice("secret_password_hash")
.store_uint(user_private_key, 256)
.end_cell();
Everything on blockchain is public. Transaction history, contract storage, and message contents are permanently visible to everyone.
Account destruction race conditions
Severity: 🔴 Critical
Destroying accounts without proper checks can lead to fund loss in race conditions.
Vulnerable code:
() recv_internal(msg_value, in_msg_full, in_msg_body) {
if (in_msg_body.slice_empty?()) {
return (); ;; Dangerous: empty message handling
}
;; Process and destroy account
send_raw_message(msg, 128 + 32); ;; Destroys account
}
Secure approach:
() recv_internal(msg_value, in_msg_full, in_msg_body) {
;; Proper validation before any destruction
throw_unless(error::unauthorized, authorized_sender?(sender));
;; Ensure no pending operations
throw_unless(error::pending_operations, safe_to_destroy?());
;; Then proceed with destruction if really needed
}
Missing replay protection
Severity: 🔴 Critical
External messages without replay protection can be re-executed multiple times.
Secure implementation:
() recv_external(slice in_msg) impure {
slice ds = get_data().begin_parse();
int stored_seqno = ds~load_uint(32);
int msg_seqno = in_msg~load_uint(32);
throw_unless(33, msg_seqno == stored_seqno); ;; Prevent replay
accept_message();
;; Update sequence number
set_data(begin_cell().store_uint(stored_seqno + 1, 32)...);
}
Improper message bounce handling
Severity: 🔴 Critical
Using non-bounceable messages when bounceable is needed can cause fund loss.
Vulnerable code:
var msg = begin_cell()
.store_uint(0x10, 6) ;; Non-bounceable
.store_slice(to_address)
.store_coins(amount)
.end_cell();
Secure implementation:
var msg = begin_cell()
.store_uint(0x18, 6) ;; Bounceable message
.store_slice(to_address)
.store_coins(amount)
.store_uint(0, 1 + 4 + 4 + 64 + 32 + 1 + 1)
.store_uint(op::excesses(), 32)
.end_cell();
Executing third-party code
Severity: 🔴 Critical
Executing untrusted code can compromise contract security.
Prevention:
;; Validate all external code before execution
throw_unless(error::untrusted_code, verify_code_signature(code));
throw_unless(error::invalid_code, validate_code_safety(code));
Medium
Race conditions in message flows
Severity: 🟡 Medium
Message cascades can span multiple blocks, allowing attackers to initiate parallel flows.
Best practice:
() handle_transfer(slice sender, int amount) impure {
int current_balance = get_balance(sender);
throw_unless(error::insufficient_funds, current_balance >= amount);
;; Don't assume balance will remain the same in subsequent messages
}
Improper gas management
Severity: 🟡 Medium
Failing to return excess gas or miscalculating gas requirements.
Secure gas handling:
int ton_balance_before_msg = my_ton_balance - msg_value;
int storage_fee = const::min_tons_for_storage - min(ton_balance_before_msg, const::min_tons_for_storage);
msg_value -= storage_fee + const::gas_consumption;
if (msg_value > 0) {
var msg = begin_cell()
.store_uint(0x18, 6) // Bounceable message
.store_slice(response_address)
.store_coins(msg_value)
.store_uint(0, 1 + 4 + 4 + 64 + 32 + 1 + 1)
.store_uint(op::excesses(), 32)
.store_uint(query_id, 64)
.end_cell();
send_raw_message(msg, 1);
}
Fake jetton token validation
Severity: 🟡 Medium
Accepting transfers without validating the sender's jetton wallet address.
Secure validation:
() handle_jetton_transfer(slice sender_address, int jetton_amount, slice from_user) impure {
;; Calculate expected jetton wallet address
slice expected_wallet = calculate_jetton_wallet_address(from_user, jetton_master_address);
throw_unless(error::invalid_jetton_wallet, equal_slice_bits(sender_address, expected_wallet));
;; Process valid transfer
}