Cardano vulnerabilities #5: Token Security

Vacuumlabs Auditing
8 min readApr 26, 2024

--

Native tokens introduced in the Mary protocol upgrade are an essential part of the Cardano blockchain. They represent value that can be created and traded on the blockchain in addition to ADA.

Tokens can be more than just a representation of value. While interacting with Cardano, you will encounter a myriad of different tokens serving various purposes. It is therefore important to know what the tokens are, and to understand their common use and security pitfalls.

In this blog we will focus on the basics — what tokens are, how they integrate into the Cardano blockchain and how they can be incorporated into dApps. We will focus on what challenges they represent for smart contract developers. In our next blog, we will look at more interesting use cases that show the true potential of native tokens.

To make things simple, we will use the word token to represent both ADA and native tokens. However, note that ADA is a special case and its handling may be different from time to time, even though it behaves similarly to native tokens in a lot of cases.

Basics

A token on the Cardano blockchain is an asset that can be stored inside an Unspent Transaction Output (UTxO). Tokens can be minted (new tokens are created), burned (existing tokens are destroyed), or transferred. Any token is defined by two parameters — a policy ID and an asset name sometimes called its token name. The rules detailing how and if new tokens can be minted or old tokens burned is written in a minting policy. A minting policy is a smart contract and it is linked to the tokens through the tokens’ policy ID (the first identifier of a token). More specifically, the policy ID of a token is a cryptographic hash of its minting policy’s code. Tokens that have different minting policies are very different. Tokens that are different only in the token name are somewhat related, they are governed by the same smart contract.

The policy specifies the conditions under which tokens can be minted or burned. If any token is minted or burned in a transaction, the minting policy of that token must be part of the transaction and must successfully validate the operation. However, tokens’ transfer alone does not execute the minting policy and the transfer therefore can’t be controlled by the token’s code. Note: Stay tuned for our programmable tokens’ design that will feature how programmable on-transfer functionality could be added.

Token names are additional data associated with tokens. They are set by the minting party in the minting transactions. The minting policy governs the minting of all tokens with the same policy ID, even though they can have different token names. More often than not, it governs what token names can look like and under which circumstances.

Note that in a single transaction, each minting policy is run only once. If the transaction mints and/or burns several tokens with the same policy ID but different token names, the corresponding minting policy is run only once and it needs to validate all the policy’s minted and burned tokens.

validator {
fn mint_and_burn(_redeemer: Void, ctx: ScriptContext) -> Bool {
let ScriptContext { transaction, purpose } = ctx
expect Mint(own_policy_id) = purpose
let Transaction { mint, ..} = transaction

let mint_value = value.from_minted_value(mint)
let own_tokens = dict.to_list(value.tokens(mint_value, own_policy_id)

// Allowing only mint, not burn
list.all(
tokens,
fn(token) {
let (asset_name, amount) = token
amount > 0
},
)
}
}

The code above shows a simple minting policy that lets anyone mint any tokens, but does not allow burning them at all. Note that the transaction.mint contains all the tokens minted or burned in the transaction. We therefore want to filter out only tokens governed by our minting policy whose id we extracted to the policy_id variable. The resulting list can contain a number of records. Each is a pair consisting of an asset name and an amount of tokens of that asset name that were minted (representing a positive number) or burned (representing a negative number). Finally, the final check makes sure that all the amounts are positive, meaning that no token was burned. Note that it does not restrict anything else and you could mint tokens of any token name under this policy. However, naturally, you can not mint tokens of a different policy using this code. For that, you need the other policy to validate.

It is therefore possible to create tokens that:

  • can be minted only with a specific token name.
  • can be minted only once — this can be used to create NFTs.
  • can be minted only if another token of the same policy ID and a different name is burned.
  • can be minted only if enough ADA is deposited into a specified UTxO.
  • can be burned only if the transaction is signed by a specified key.
  • cannot be burned under any circumstances.

Note that the policy ID of ADA is an empty string. Therefore, there is no minting policy that corresponds to it. As a consequence, it is not possible to mint or burn ADA this way.

Tokens as value

Many tokens represent value. Whether it is ADA, stablecoins, NFTs or tokens minted by promising projects, they have value and are often stored and transferred between various UTxOs.

As a developer, you need to ensure that you handle these tokens correctly when designing your dApp. Let’s assume you have created a simple treasury to which value can be freely added by anyone, but can only be redeemed in a correctly signed multi-signature transaction. For example, such treasury could be used by a DAO that’s selling NFTs on a marketplace. Users buying the NFTs would pay into this treasury, and they can withdraw the tokens only if multiple members of the DAO agree on how they want to use the funds received. This protocol can be demonstrated in these two simple transactions.

What are the security risks you want to prevent when implementing this treasury?

Let’s focus on the first transaction type — a transaction in which additional value is added to the treasury. The user submitting such a transaction should not be able to withdraw anything from the UTxO so you implement a simple check that “Amount of ADA present in the output UTxO is at least the amount of ADA present in the input UTxO”.

This works just fine for ADA (double satisfaction vulnerability aside), but there are no restrictions placed on any other tokens. In our example with the DAO selling NFTs, the DAO decides to sell an NFT for some tokens other than ADA, and someone buys such an NFT. Let’s assume that the trade went through and the DAO balance was updated correctly. Anyone can retrieve those tokens simply by spending the DAO’s UTxO and withdrawing the tokens. As we saw, the validator only checks that no ADA is stolen. This is an example of a vulnerability in the token handling. We will now show how to fix it.

Value size and execution limits

So we can edit the treasury validator to check the following condition:

For each token type present in the input treasury UTxO, at least that amount of the same tokens must be present in the output treasury UTxO.

This is better, as it guarantees that the value in the treasury UTxO only accumulates, until the DAO members withdraw from the treasury via a multi-signature scheme.

But there are glaring security issues with this approach which are apparent in a broader context of the Cardano blockchain. Each transaction needs to go through two validation phases. In the second phase, the scripts are executed. To avoid unproportional spending of resources there are several limits that Cardano enforces.

The first important limit in this context is the maximum value size limit. This is a protocol parameter, which is currently set to 5000 bytes. All the tokens in the value contribute towards the value size, and therefore this limits the amount of different tokens we can hold in a single UTxO. To exploit this limit, an attacker could DDoS the treasury by minting many different worthless tokens into it, thus disabling other people from contributing valuable tokens. We call this attack “dust tokens”.

The multi-signature parties could mitigate this issue by withdrawing the worthless tokens and thus allowing others to contribute. However, this does not prevent the attacker from repeating the attack again.

The other relevant limits are the execution limits. Execution limits limit the amount of time and memory units for each transaction’s script execution. Again, these limits can not be exceeded. For example, if we parse the value in the transaction validation, this counts towards both the time and the memory execution limits.

By attacking these execution limits, an attacker could block even the withdrawal operation, depending on its complexity. The withdrawal transaction can be more complicated than the depositing transaction, as it needs to find and run two validators instead of one. If our validator runs code in which the resource consumption depends on the number of tokens (e.g. for each token type as in our case), the artificially enlarged UTxO can potentially stretch the execution limits to such an extent that the withdrawal validation can not fit in the execution limits. That effectively makes it impossible for the value contained within to be withdrawn unless an (unlikely) protocol change is made, e.g. increasing the limits.

Correct value handling

As always, theory is a lot cleaner than practice. The actual execution limits are determined by the validators that are run where it heavily depends on their actual implementation — how complex and efficient it is. Moreover, it also depends heavily on the context the validators are run on, the transaction validated, its structure and size.

Unfortunately, there is no simple catch-all solution for token handling. The best course of action is first think about the types of tokens that can be deposited into the UTxO. If you only really care about ADA, enforce that no other tokens are added. If you need to support multiple policies, specify the set of policy IDs in the datum and test that the validator is efficient enough that it validates even when it contains all of them at the same time. If you need to be able to support any tokens, you could limit how many different tokens can be in a single UTxO. You might require a different solution altogether, though.

As always, rigorous testing should be in place to test whether even the most extreme edge cases allowed by your validator fit into the execution limits. Remember to try enlarging the transaction as well when testing, e.g. increase the number of normal inputs and outputs, put tokens into those artificial inputs and outputs as well, etc.

What’s next?

In this blog, we introduced native tokens and went through the basics of secure token handling on Cardano. You can try to apply the gained knowledge practically in our Capture the Flag game. Check level 06_tipjar_v2 and see if you can find the vulnerability in the smart contract. As the level is a harder version of 04_tipjar, make sure to solve that first!

There will also be a second blog focused on tokens where we look at how they can be used to do more than just represent value.

--

--

Vacuumlabs Auditing

Expert team of smart contract auditors ensuring safety and efficiency in the blockchain world. Join for insightful crypto knowledge.