Algorand Rekeying Attacks Explained
In this post, we’ll look into some of Algorand’s particularities, and you’ll learn how to exploit the lack of transaction field validation to gain full control of accounts and smart contract systems using smart signatures.
First, we’ll quickly go through some basic Algorand concepts, and then we’ll look into a vulnerability we regularly find during our Algorand smart contracts audits. If you already have a good understanding of how Algorand’s smart signatures work, feel free to jump right into the example.
Smart Contracts vs Smart Signatures
Let’s look at the difference between smart contracts and smart signatures. In Algorand, smart contracts work similarly to smart contracts in Ethereum; users submit application call transactions to the network and the relevant smart contract code handles it, approving or rejecting the transaction. Smart signatures work a little differently, users submit a transaction with the smart signature as sender. Later, when the transaction is processed, the smart signature code executes, approving or rejecting the transaction.
We can think of Contract accounts as stateless smart contracts. They are addresses generated from hashing TEAL code, and any transaction submitted to the network that is not rejected by the program will be approved. They are stateless because the program cannot access any type of storage, whereas smart contracts can use local and global storage. Delegated signatures, as their name implies, are signatures delegated to programs, meaning that a user can authorize a smart signature to approve transactions on their behalf.
The main difference between smart contracts and smart signatures (for this post) is that users interact with smart contracts (i.e. a user submits an ApplicationCall
transaction) versus users submitting transactions for the smart signature. In the former case, the sender is the address of the user and in the latter, the sender is the address associated with the smart signature. This difference is key because in the latter case: anybody can submit any transactions from the smart signature (e.g., Payments
or AssetTransfers
).
Simple Example
Let’s say I’m the owner of a very important token and I want users to be able to automatically buy them for a fixed amount of ALGOs (Algorand’s native currency). Tokens and NFTs are implemented with Algorand Standard Assets (ASAs). Ideally, I would like any user to be able to submit a Payment
(from their account to mine) and an AssetTransfer
(from my account to theirs) without my active participation. One way I can achieve this is by delegating my signature to a smart signature and letting it approve the transactions for me:
# Code is explained further down
from pyteal import *
def lsig_program():
group_size = Int(2)
asset_price = Int(42000)
current_index = Int(1)
asset_amount = Int(1)
owner_address = Addr(“4242424242424242424242424242424242424242424242424242424242”)
asset_id = Int(42)
return Assert(
And(
Eq(Global.group_size(), group_size),
Eq(Gtxn[0].type(), TxnType.`Payment`),
Eq(Gtxn[0].receiver(), owner_address),
Eq(Gtxn[0].amount(), asset_price),
Eq(Txn.group_index(), current_index),
Eq(Txn.type(), TxnType.AssetTransfer),
Eq(Txn.xfer_asset(), asset_id),
Eq(Txn.asset_amount(), asset_amount),
Eq(Txn.asset_close_to(), Global.zero_address()),
)
)
if __name__ == "__main__":
print(
compileTeal(
lsig_program(), mode=Mode.Signature, version=2
)
)
The code above uses PyTeal, a python library that generates TEAL programs; the function defined lsig_program
returns a PyTeal Expr which can later be compiled into TEAL using compileTeal
, which returns the program as a string.
lsig_program
defines some constants and proceeds to validate the current transaction group:
Assert
validates that the input expression evaluates to Int(1)
; And
takes any number of expressions and returns the logical conjunction of them. We explain below the list of validations from the code above:
- The transaction group size equals two.
- The first transaction is a
Payment
. - The
Payment
receiver owns the smart signature. - The paid amount equals the asset price.
- The validating transaction (current transaction) is the second in the group.
- The current transaction is an asset transfer.
- The transferred asset is the right one.
- The amount transferred is one.
- The current transaction does not perform an asset close.
Looks pretty safe, right? Well, it has a pretty serious security issue. The code above fails to check for a key field: the rekeying field.
What is rekeying?
Rekeying is a powerful protocol feature that enables an Algorand account holder to maintain a static public address while dynamically rotating the authoritative private spending key(s) - Algorand’s documentation
In other words, it allows you to set up a new signer for your account. This new signer becomes the owner of the account and from now on you are going to need their private key to sign any transaction from your account, and previous ones are not going to work anymore. To rekey an account, you just have to submit a transaction (any transaction type works) and specify the rekey_to
field of the new signer. To be clear, the sender of a transaction with the rekey_to
field set to a non-zero address will be rekeyed to the new address specified.
Back to the problem.
By now you might be seeing the problem in the example: By not validating the rekey_to
in the asset transfer, it allows any person to take over your address! Since the smart signature allows other users to submit asset transfers with your address as sender, an attacker could submit one with the rekey_to
field set to their address and gain full control of your account.
How do we fix it?
We check for the rekey_to
field to be equal to the zero address. If we add the following line to the validation list:
Eq(Txn.rekey_to(), Global.zero_address())
We ensure that the transaction does not perform a rekey.
WARNING
One might think: “Well, that’s a pretty simple check and I would never make that mistake when adding a delegated signature to my account”, and you might not. However, during our audits, we found that many contract accounts fail to validate the rekeying field correctly.
Real-world example: Algorand Name Service
The Algorand Name Service implements a key-value store of name-address pairs using an Algorand stateful smart contract and smart signatures. The implementation is very different to name services in other networks, for full details read the ANS documentation.
In ANS, there is a single central smart contract (or registry) per domain suffix and multiple contract accounts that act as the registry’s records. These records share the same code and are generated with the desired name as input. For instance, let’s say I want to buy “rekeying.algo”, I would compute the address generated by hashing the compiled TEAL program with “rekeying” as input. And with it, I would submit the purchase group transaction, setting myself as the owner of the record store. Let’s take a look at the smart signature source code: For simplicity, we’ll only take a look at the relevant parts of the transaction validator function. You can find all of it at contracts/dot_algo_name_record.py. Let’s break it down:
Ensures 2 types of group transactions, one where group size is two, and one where it is four.
Assert(
Or(
Global.group_size() == Int(2),
Global.group_size() == Int(4)
)
),
In both cases, the first two transactions have to come from the same address, the buyer, and it ensures Gtx[0]
is a Payment
(the amount is validated elsewhere) to the DOT_ALGO_ESCROW_ADDRESS
AKA the registry.
Assert(Gtxn[0].sender() == Gtxn[1].sender()),
Assert(Gtxn[0].receiver() == Addr(DOT_ALGO_ESCROW_ADDRESS)),
Let us see first the case where group_size is equal to 4 (in the source code, the case where group_size equals 2 comes first), which would be the first time somebody wants to register the name record to themselves. It asserts the following validations:
- The second transaction is another
Payment
from the buyer to the sender of Gtx[2], which should be the record itself; thisPayment
is required so that the record can later opt-in to the registry. - The third transaction is the record opt-in into the registry.
- The fourth transaction is a
register_name
call to the registry from the buyer to put itself as the owner in the record storage
.ElseIf(Global.group_size() == Int(4))
.Then(
Assert(
And(
#1
Gtxn[1].receiver() == Gtxn[2].sender(),
#2
Gtxn[2].application_id() == Int(DOT_ALGO_APP_ID),
Gtxn[2].on_completion() == OnComplete.OptIn,
#3
Gtxn[3].application_id() == Int(DOT_ALGO_APP_ID),
Gtxn[3].sender() == Gtxn[0].sender(),
Gtxn[3].application_args[0] == Bytes("register_name"),
Gtxn[3].application_args[1] == Bytes(name)
)
)
The case where group_size
is 2, is when the smart signature has already opted-in. This case is when the previous owner’s time expired, and a new buyer is trying to get it for themselves
The second transaction is just the register_name
app call.
If(Global.group_size() == Int(2))
.Then(
Assert(
And(
Gtxn[1].application_id() == Int(DOT_ALGO_APP_ID),
Gtxn[1].application_args[0] == Bytes("register_name"),
Gtxn[1].application_args[1] == Bytes(name)
)
)
)
It’s easy to see that the rekeying field validation is nowhere to be found, and the registry lacks them too. It repeats the same validation, plus checking that someone has not bought the record yet or if it has, that the purchase has already expired.
Assert(
Or(
get_name_status.hasValue() == Int(0),
Global.latest_timestamp() >= current_expiry
)
),
(You can see the rest of the code at contracts/dot_algo_registry.py)
Therefore, an attacker could submit the following group transaction to gain total control of a record that has not been bought yet (since the opt-in would fail otherwise):
Payment
from the attacker to the Registry Smart contract.Payment
from the attacker to the record.- Opt-in from the record into the Registry Smart contract,
rekey_to
attacker. - Call register_name from the attacker to the registry smart contract.
If we go back to the code, we can see that this transaction would pass all validations, and no other user will be able to use this record ever again. But wait! There’s more! By switching the roles of the addresses:
Payment
from the record to the Registry Smart contract.Payment
from the record to the attacker’s address (can be 0).- Opt-in from the attacker address into the Registry Smart contract.
- Call register_name from the record to the Registry Smart contract,
rekey_to
attacker.
This group transaction would allow us to rekey any record, even if it has already been bought! This is bad. At first, it may seem that the attacker only prevents users from purchasing the name after expiry, but it is way worse. The attacker can submit a ClearState
transaction from the record and remove the previous owner! After that, they could register themselves if they wanted to. You might think: “Wait a minute, the record validation function does not allow any ClearState
transactions”, and it does not indeed. However, since the attackers gain FULL control over the record, the code does not matter anymore, the attacker can submit whatever transaction they want to the network from the record. Pretty bad, isn’t it?. Fortunately, it’s been patched in more recent commits, and the domains are safe. You can see our full report on ANS here
Conclusion
It is an excellent practice to validate ALL transaction fields. Check out the Algorand Guidelines and take a look at the complete ANS report.