In the previous article, we discussed front running attacks within Ethereum and the various stages a transaction undergoes from initiation and signing by the sender to being included in a block by the miners. This time, we’ll focus on a classic vulnerability in smart contracts known as Signature Replay.
Background
Logically, a transaction, once signed, should be executed only once. If a transaction can be executed multiple times, it poses a risk of a Replay Attack. To understand Replay Attacks, we first need to understand the various parameters that make up a signed transaction:
type txdata struct { AccountNonce uint64 `json:"nonce" gencodec:"required"` Price *big.Int `json:"gasPrice" gencodec:"required"` GasLimit uint64 `json:"gas" gencodec:"required"` Recipient *common.Address `json:"to" rlp:"nil"` Amount *big.Int `json:"value" gencodec:"required"` Payload []byte `json:"input" gencodec:"required"`
// Signature values V *big.Int `json:"v" gencodec:"required"` R *big.Int `json:"r" gencodec:"required"` S *big.Int `json:"s" gencodec:"required"`
// This is only used when marshaling to JSON. Hash *common.Hash `json:"hash" rlp:"-"`}
Here, we’ll explain each of these parameters:
AccountNonce
AccountNonce is a numerical value associated with an account, used to ensure transactional order and uniqueness within the blockchain network. Each account in the blockchain has an associated Nonce (also known as transaction count or transaction index), indicating the number of transactions initiated by the account. It is the protagonist of this article, whose main purpose is to prevent Replay Attacks. Whenever an account sends a transaction, the Nonce value automatically increases. Upon receiving the transaction, the network checks whether the Nonce in the transaction matches the current Nonce of the account to ensure the correct order of transactions and prevent duplicate executions.
How does Nonce ensure the order of transactions?
Given that the blockchain is a distributed system, multiple nodes may receive different transactions simultaneously. By setting a Nonce, transactions can be sorted, ensuring that they are packaged into blocks in the correct order.
In Ethereum, Nonce follows several rules:
- If the Nonce is too small (less than the current Nonce of the account), the transaction will be rejected directly.
- If the Nonce is too large (greater than the current Nonce of the account), the transaction will remain in the queue.
- If a larger Nonce value is sent, the transaction is in a pending state. To execute this transaction, more transactions need to be sent. When the account Nonce accumulates to the submitted height, the transaction can be executed.
- A transaction queue can save up to 64 transactions from the same account. So, if you need to make batch transfers, a single node cannot issue more than 64 transactions.
- If there are still transactions in the queue of a node and the Geth client is stopped, the transactions in the queue will be cleared.
- When the current Nonce is appropriate, but the account balance is insufficient, the transaction will also be rejected by Ethereum.
Price
The GasPrice of the transaction. (Refer to the previous article)
GasLimit
The maximum Gas the transaction is allowed to consume. (Refer to the previous article)
Recipient
If the recipient of the transaction is empty, this transaction is a contract deployment transaction.
The recipient is also a field in Ethereum code, renamed to ‘to’ when converted to JSON. The recipient of the transaction is specified in the ‘to’ field, which contains a 20-byte Ethereum address. The address can be an EOA or contract address.
Ethereum does not verify this field further; any 20-byte value is considered valid. Even if the recipient address is unclaimed, the transaction is still valid. If it is a transfer transaction, Ether will be sent to the specified address. However, since the private key of the specified address cannot be obtained, the control of this money is lost, hence, ETH is lost.
Amount
The ‘Amount’ indicates the quantity of ETH transferred by the transaction, in wei.
Payload
When the transaction is a contract deployment transaction, the ‘Payload’ field represents the content of the contract deployment; otherwise, it represents the contract calling code, which contains the signature of the function to be called and the function parameters.
VRS
V: It is a value used to recover public keys and represents the index of the point on the elliptic curve used for the signature. In Ethereum, the value of V is typically 27 or 28, but it can sometimes be different. The actual value is calculated using the formula: V = ChainId * 2 + 35 + RecoveryId, where ChainId is the ID used to identify the Ethereum network chain and RecoveryId is an additional value used to recover public keys. After the Ethereum London upgrade, the mainnet chain ID is independently encoded and is no longer included in the signature V value. The signature V value has become a simple parity check (“signature Y parity”), which is either 0 or 1, depending on which point on the elliptic curve is used.
R: Is a part of the signature and represents the x-coordinate on the elliptic curve.
S: Is another part of the signature and represents a parameter on the elliptic curve.
Signatures in the VRS format make it easy to extract public keys and verify the validity of signatures. It’s important to note that while VRS format signatures are widely used in Ethereum, other cryptocurrencies and blockchain networks may have different signature formats.
Signature replays in Ethereum can be roughly divided into two types:
- Cross-chain Signature Replay Attacks
A cross-chain signature replay, as the name suggests, replays transactions on different chains to complete an attack. The most typical example is the theft of 20 million OP tokens from Optimism on June 9, 2022. This incident occurred because the Gnosis Safe wallet contract transaction signature did not conform to the EIP155 standard (here’s a brief introduction to the EIP155 tag: EIP155 standard signatures will hash 9 RLP-encoded elements (nonce, gasPrice, gas, to, value, data, chainId, 0, 0), which includes the chainId, therefore, EIP155 standard signature V value is {0,1} + chainId * 2 + 35. For non-conforming EIP155 standard signatures, only 6 elements are hashed (nonce, gasPrice, gas, to, value, data), so the signed V value is {0,1} + 27). We can easily find that transactions not using the EIP155 standard do not have a chainId in their signatures, leading to the possibility that a transaction can be replayed on other chains.
The infamous attacker in the Optimism incident took advantage of this. They found the input data of the Gnosis Safe proxy factory contract deployed on the Ethereum mainnet and replayed the transaction on the Optimism chain to deploy the proxy factory contract. They kept calling the contract to create wallet contracts until the Nonce reached a height where they could generate an address that held 20 million OP. They took control of that address and completed the attack. The attack details can be found in “20 Million OP Tokens Stolen: Transaction Replay”.
2. Same Chain Signature Replay Attacks
Same-chain signature replay attacks usually exploit contract vulnerabilities to carry out the attack. The most typical situation is when the contract does not include a Nonce when generating a signature, resulting in the signature data being infinitely usable and causing harm. This article mainly introduces the principle of this type of attack and how to prevent it.
We’ll explore the detailed understanding by examining a vulnerable contract:
Contract Example
// SPDX-License-Identifier: MITpragma solidity ^0.8.17;
import "github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.5/contracts/utils/cryptography/ECDSA.sol";
contract MultiSigWallet { using ECDSA for bytes32;
address[2] public owners;
constructor(address[2] memory _owners) payable { owners = _owners; }
function deposit() external payable {}
function transfer(address _to, uint _amount, bytes[2] memory _sigs) external { bytes32 txHash = getTxHash(_to, _amount); require(_checkSigs(_sigs, txHash), "invalid sig");
(bool sent, ) = _to.call{value: _amount}(""); require(sent, "Failed to send Ether"); }
function getTxHash(address _to, uint _amount) public view returns (bytes32) { return keccak256(abi.encodePacked(_to, _amount)); }
function _checkSigs( bytes[2] memory _sigs, bytes32 _txHash) private view returns (bool) { bytes32 ethSignedHash = _txHash.toEthSignedMessageHash();
for (uint i = 0; i < _sigs.length; i++) { address signer = ethSignedHash.recover(_sigs[i]); bool valid = signer == owners[i];
if (!valid) { return false; } }
return true; }}
As you can see, the MultiSigWallet contract is a 2/2 multi-signature contract. Two Owners deposit money into the contract, and the initiator must call MultiSigWallet.getTxHash() and pass in the transfer target and transfer quantity to initiate a transfer. After getting the hash, both Owners sign with their private keys. Only after getting these two signature data, can the money be successfully transferred out of the MultiSigWallet.transfer(). Let’s have Evil, Bob, and Alice, these old friends, act out the attack process:
1. Alice and Bob jointly created the MultiSigWallet contract and deposited 10 ETH each into the contract (so the contract now holds 20 ETH).
2. Alice tells Bob that she wants to transfer 1 ETH to her boyfriend, Evil, as a birthday gift.
3. Alice calls MultiSigWallet.getTxHash() and enters Evil’s EOA address and the transfer amount to get the transaction hash.
4. Both Bob and Alice sign the generated transaction hash.
5. Alice gives the two signatures to Evil so he can withdraw the money himself.
6. Evil realizes that he can use these two signatures to infinitely call MultiSigWallet.transfer() to repeatedly transfer 1 ETH to himself.
7. Evil calls MultiSigWallet.transfer() 20 times to take away all 20 ETH from the contract.
Attack Analysis
The problem is quite straightforward. The transaction hash generated by Alice calling MultiSigWallet.getTxHash() did not include a Nonce. This omission allows the signature data to be used infinitely, enabling Evil to withdraw funds limitlessly.
Fixed Contract
By including a Nonce in the transaction hash, the replay can be perfectly prevented. Let’s see how the fixed contract is implemented:
// SPDX-License-Identifier: MITpragma solidity ^0.8.17;
import "github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.5/contracts/utils/cryptography/ECDSA.sol";
contract MultiSigWallet { using ECDSA for bytes32;
address[2] public owners; mapping(bytes32 => bool) public executed;
constructor(address[2] memory _owners) payable { owners = _owners; }
function deposit() external payable {}
function transfer( address _to, uint _amount, uint _nonce, bytes[2] memory _sigs) external { bytes32 txHash = getTxHash(_to, _amount, _nonce); require(!executed[txHash], "tx executed"); require(_checkSigs(_sigs, txHash), "invalid sig");
executed[txHash] = true;
(bool sent, ) = _to.call{value: _amount}(""); require(sent, "Failed to send Ether"); }
function getTxHash( address _to, uint _amount, uint _nonce) public view returns (bytes32) { return keccak256(abi.encodePacked(address(this), _to, _amount, _nonce)); }
function _checkSigs( bytes[2] memory _sigs, bytes32 _txHash) private view returns (bool) { bytes32 ethSignedHash = _txHash.toEthSignedMessageHash();
for (uint i = 0; i < _sigs.length; i++) { address signer = ethSignedHash.recover(_sigs[i]); bool valid = signer == owners[i];
if (!valid) { return false; } }
return true; }}
The fixed contract includes a Nonce in MultiSigWallet.getTxHash() to generate the transaction hash, and the contract also adds an ‘executed’ list. After calling MultiSigWallet.transfer() for a transfer, the signature corresponding to the status is changed to executed[txHash] = true. This addition is to prevent the same transfer from being submitted multiple times.
Conclusion
As a developer, when the business involves the use of signature data, one should evaluate whether the normal business design allows the signature to be replayed. If not, a Nonce parameter should be added.
As an auditor, during an audit, all uses of signatures need to be checked for replay potential. If they satisfy replay characteristics, it is necessary to communicate with the project team in time to determine if it matches the business design.
About SlowMist
SlowMist is a blockchain security firm established in January 2018. The firm was started by a team with over ten years of network security experience to become a global force. Our goal is to make the blockchain ecosystem as secure as possible for everyone. We are now a renowned international blockchain security firm that has worked on various well-known projects such as Huobi, OKX, Binance, imToken, Crypto.com, Amber Group, Klaytn, EOS, 1inch, PancakeSwap, TUSD, Alpaca Finance, MultiChain, Cheers UP, etc.
SlowMist offers a variety of services that include by are not limited to security audits, threat information, defense deployment, security consultants, and other security-related services. We also offer AML (Anti-money laundering) software, Vulpush (Vulnerability monitoring) , SlowMist Hacked (Crypto hack archives), FireWall.x (Smart contract firewall) , Safe Staking and other SaaS products. We have partnerships with domestic and international firms such as Akamai, BitDefender, FireEye, RC², TianJi Partners, IPIP, etc.
By delivering a comprehensive security solution customized to individual projects, we can identify risks and prevent them from occurring. Our team was able to find and publish several high-risk blockchain security flaws. By doing so, we could spread awareness and raise the security standards in the blockchain ecosystem.
Website:
https://www.slowmist.com
Twitter:
https://twitter.com/SlowMist_Team
Github:
https://github.com/slowmist/