Part 2: Leaks
Note : clone this project for compiling and testing this tutorial
Replay attack
A replay attack on a smart contract is a type of security vulnerability that allows an attacker to reuse a signed valid transaction multiple times. We saw in the previous chapter how to do offchain replay attacks, but it is possible to do also onchain replay attacks. Besides, Tezos prevents this kind of vulnerability, it is possible to write a code that does this attack sending the same operation several times for execution.
Compile and simulate the replay attack :
taq init
taq compile 1-replay.jsligo
taq simulate 1-replay.tz --param=1-replay.parameter.parameter.tz
The simulation will tell you that several internal transactions will be executed. But if you deploy the code and try to execute it :
taq deploy 1-replay.tz --mutez 1000 -e testing
taq transfer KT1VMt7t4CboRP6jYBUdBQowHb4NR1UtmDrz -e testing
Then, the Tezos will detect the flaw
"message": "(transaction) proto.017-PtNairob.internal_operation_replay"
Memory overflow
Memory overflow is a kind of attack that overloads the memory of a smart contract resulting in making this contract unusable. Even simply loading the data into memory and deserializing it at the beginning of the call could use so much gas that any call to the contract would fail. All the funds would be forever locked into the contract.
Here is the list of dangerous types to use carefully :
- Integers and nats: as they can be increased to an arbitrarily large value
- Strings: as there is no limit on their lengths
- Lists, sets, maps: that can contain an arbitrary number of items
→ SOLUTION:
- Ask the user to pay a minimum tez for each call
- Set a threshold limit
- Store data on a big_map
- Avoid unnecessary onchain computation that can be done offchain. Ex: do not loop onchain and just update a part of a map
Example with the current FA1.2 implementation: https://inference.ag/blog/2023-10-09-FA12_spenders/
You can have a look at the LIGO implementation of fa1.2 on the Ligo registry here
The code follows the standard but you can see that the Allowance type is a map. It would have been better to change the Standard and use a big_map
instead of a map
. If you implement the Standard differently, then your smart contract storage definition and entrypoint signatures will not match anymore and will not be supported by other platforms
Re-entrancy
These attacks allow an attacker to repeatedly call a contract function in a way that drains the contract’s resources, leading to a denial of service (DoS) attack
One of the most well-known examples of a re-entrancy attack occurred in 2016 when an attacker exploited a vulnerability in the DAO (Decentralized Autonomous Organization) contract on the Ethereum blockchain. But this popular hack is still actively used :
- Uniswap/Lendf.Me hacks (April 2020) – $25 mln, attacked by a hacker using a re-entrancy.
- The BurgerSwap hack (May 2021) – $7.2 mln, because of a fake token contract and a re-entrancy exploit.
- The SURGEBNB hack (August 2021) – $4 mln, seems to be a re-entrancy-based price manipulation attack.
- CREAM FINANCE hack (August 2021) – $18.8 mln, re-entrancy vulnerability allowed the exploiter for the second borrow.
- Siren protocol hack (September 2021) – $3.5 mln, AMM pools were exploited through re-entrancy attack.
This kind of attack is quite simple to put in place with Solidity because of the way it works.
Consider this scenario :
Why this scenario is possible on Solidity? On Solidity, the operation will call directly the smart contract, stopping the execution, calling a synchronous execution and resuming the flow. If someone calls several times and very quickly the withdraw operation, as the state is not updated yet, the call will succeed and drain more than the developer expected.
Why this scenario is not possible on Tezos? On Tezos, the first transaction will update the state and will execute a list of operations at the end of execution. Next executions will encounter an updated state
Let's implement a more complex scenario where the OfferContract and LedgerContract are separated. The OfferContract will naively send the money back to MaliciousContract because it relies on the not yet modified state of the LedgerContract. There are two operations and the modification of the state will come in second position.
The issue here is clearly that we send money without updating the state first
→ SOLUTION :
-
Mutex safeguard: To prevent the contract from generating multiple internal operations, we can add a Mutual Exclusive semaphore Boolean named
isRunning
that is true when an operation is running. This variable locks the contract while the full transaction flow runs.- Check the isRunning is false
- Set isRunning to true
- Do logic code ...
- Create a last operation transaction to reset the boolean to false
-
Check-and-send pattern: Principle of separating state changes from external contract interactions. First, update the contract’s state, then interact with other contracts
Compile/Run the hack test first
taq test 3-reentrancyTest.jsligo
The logs seem to be fine, but it is hard to guess the internal transactions and to separate the fees from the hack on the attacker's balance
┌─────────────────────────┬─────────────────────────────────────────────┐
│ Contract │ Test Results │
├─────────────────────────┼─────────────────────────────────────────────┤
│ 3-reentrancyTest.jsligo │ "ledgerContract" │
│ │ KT1LQyTHEZeaecRj7hWgkzPEBD6vMEKXYzoo(None) │
│ │ "offerContract" │
│ │ KT1M4nPCej4va4Q2iMPX2FKt8xLw5cfGjBv9(None) │
│ │ "maliciousContract" │
│ │ KT1B7RgF6j7UpAybpdfxhLCp7hf41pNFcxyS(None) │
│ │ "admin initialize cookies to malicious KT1" │
│ │ Success (1299n) │
│ │ "COOKIES OWNERS" │
│ │ {KT1B7RgF6j7UpAybpdfxhLCp7hf41pNFcxyS} │
│ │ "BALANCE OF SENDER" │
│ │ 3799985579750mutez │
│ │ Success (1798n) │
│ │ "AFTER RUN - BALANCE OF SENDER" │
│ │ 3799984579749mutez │
│ │ {KT1LQyTHEZeaecRj7hWgkzPEBD6vMEKXYzoo} │
│ │ "END RUN - BALANCE OF SENDER" │
│ │ 3799984579749mutez │
│ │ {KT1LQyTHEZeaecRj7hWgkzPEBD6vMEKXYzoo} │
│ │ Everything at the top-level was executed. │
│ │ - testReentrancy exited with value true. │
│ │ │
│ │ 🎉 All tests passed 🎉 │
└─────────────────────────┴─────────────────────────────────────────────┘
To have a better visualization of the hack, the contract should be deployed
Compile the first contract, the Ledger contract, and deploy it
taq compile 3-reentrancyLedgerContract.jsligo
taq deploy 3-reentrancyLedgerContract.tz -e testing
Copy the contract address, in my case KT1BJZfhC459WqCVJzPmu3vJSWFFkvyi9k1u, and paste it on the file 3-reentrancyOfferContract.storageList.jsligo
with your value
Compile/deploy the second contract, the Offer contract, putting some money on the contract for the thieves
taq compile 3-reentrancyOfferContract.jsligo
taq deploy 3-reentrancyOfferContract.tz -e testing --mutez 10000000
Copy the contract address, in my case KT1CHJgXEdBPktNNPGTDaL8XEAzJV9fjSkrZ, and paste it on the file 3-reentrancyMaliciousContract.storageList.jsligo
with your value
Compile/deploy the last contract, the Malicious contract which will loop and steal the funds of the Offer contract
taq compile 3-reentrancyMaliciousContract.jsligo
taq deploy 3-reentrancyMaliciousContract.tz -e testing
Copy the contract address, in my case KT1NKLZE9HkGJxjopowLqxA4pswutgMrrXyE, and initialize the Ledger contract as the Malicious contract as some cookies on its storage. Paste the value in the file 3-reentrancyLedgerContract.parameterList.jsligo
Once done, compile the Ledger contracts and call with this parameter
taq compile 3-reentrancyLedgerContract.jsligo
taq call 3-reentrancyLedgerContract --param 3-reentrancyLedgerContract.parameter.default_parameter.tz -e testing
Context is ready:
- The Malicious contract has cookies on the Ledger contract
- All deployed contract points to the correct addresses
Now the Malicious contract will try to steal funds from the Offer contract, run the command to start the attack the transaction flow
octez-client transfer 0 from alice to KT1NKLZE9HkGJxjopowLqxA4pswutgMrrXyE --entrypoint attack --arg 'Unit' --burn-cap 1
Here you can see the result on the Ghostnet: https://ghostnet.tzkt.io/KT1NKLZE9HkGJxjopowLqxA4pswutgMrrXyE/operations/
3 refunds will be emitted instead of one
→ SOLUTION: on the 3-reentrancyOfferContract.jsligo
file, line 34, swap the order of operation execution
from
return [list([opTx, opChangeOwner]), s];
to
return [list([opChangeOwner,opTx]), s];
and rerun the scenario from scratch redeploying the contracts. It should be impossible to run the attack, as the transaction will fail
"message":"user do not have cookies"
-
Authorize withdraw transfer only to a user account: As User wallet cannot do callback loops, it solves the issue but this solution is not always feasible and limiting. To check if an address is implicit, the Tezos.get_sender and the Tezos.get_source are always equal.
-
Audit External Contract calls: This is very hard to check, for example on withdrawal for a token transfer, any contract can receive funds.
-
Call third-party security experts or employ automated security tools: If you are not sure about your code, they will identify weaknesses and validate the contract’s security measures.
Overflow
Manipulating arithmetic operations can lead to overflows and underflows
-
On Solidity: SafeMath is a library in Solidity that was designed to provide safe mathematical operations. It prevents overflow and underflow errors when working with unsigned integers (uint), which can lead to unexpected behavior in smart contracts. However, since Solidity v0.8.0, this library has been made obsolete as the language itself starts to include internal checking.
-
On LIGO: For the nat, int, and timestamp types, the Michelson interpreter uses arbitrary-precision arithmetic provided by the OCaml Zarith library. It means that their size is only limited by gas or storage limits. You can store huge numbers in a contract without reaching the limit. However, in LIGO, an overflow will cause the contract to fail.
→ SOLUTION :
- For large tez values, do operation on int or nat as it has larger memory values
- There is no other solution than using types with larger values as the default behavior is to reject the transaction in case of overflow
Do not confuse with Ligo MathLib library providing manipulation of floats and rationals instead of using basic types.