Race condition in Bitcoin client leads to double spend
Description
The Bitcoin client is used to watch for cross-chain transactions as well as to relay transactions to and from the Bitcoin chain. There are numerous functions in the client, but the relevant functions are described below:
IsSendOutTxProcessed()
- Checks theob.submittedTx[outTxID]
to see whether the transaction in question has already been submitted for relaying.startSendScheduler()
- Runs every three seconds. This function gets all pending CCTX and checks if they have already been submitted withIsSendOutTxProcessed()
. If the CCTX has not been submitted, it will callTryProcessOutTx()
.TryProcessOutTx()
- Signs and broadcasts a CCTX, then adds it to a tracker in thex/crosschain
module withAddTxHashToOutTxTracker()
.observeOutTx()
- Runs every two seconds. It queries for all transactions that have been added to the tracker in thex/crosschain
module and adds them toob.submittedTx[outTxID]
.
Impact
The bug here occurs due to the racy check in IsSendOutTxProcessed()
. More specifically, the following scenario would lead to the bug:
First,
startSendScheduler()
runs and gets a pending CCTX. It checks that the CCTX has not been processed (i.e., has not been added toob.submittedTx[]
, soIsSendOutTxProcessed()
returns false), and thus callsTryProcessOutTx()
.Then,
TryProcessOutTx()
signs the CCTX and broadcasts it, then adds it to the tracker in thex/crosschain
module.After,
startSendScheduler()
runs again beforeobserveOutTx()
is able to run. The CCTX is in thex/crosschain
module tracker but not yet inob.submittedTx[]
sinceobserveOutTx()
has not run yet. Therefore,TryProcessOutTx()
is called again.Then
TryProcessOutTx()
runs, signs, broadcasts, and adds the same CCTX to the tracker in thex/crosschain
module.Finally,
observeOutTx()
runs and adds (or in this case, overwrites) the CCTX toob.submittedTx[]
.
The bug occurs in step 3. Since observeOutTx()
is responsible for adding the CCTX to the ob.submittedTx[]
map, the intention is for observeOutTx()
to run before startSendScheduler()
runs again. Due to the racy nature of the code though, this does not happen, and thus the bug is triggered.
The bug triggers with the current smoke tests by modifying the following line of code in bitcoin_client.go
to make observeOutTx()
run every 30 seconds.
func (ob *BitcoinChainClient) observeOutTx() {
ticker := time.NewTicker(30 * time.Second)
// [ ... ]
}
Recommendations
A naive fix for this bug is to modify IsSendOutTxProcessed()
to make it query for pending CCTXs in the x/crosschain
module's tracker instead. This will prevent this issue from occurring, as startSendScheduler()
and TryProcessOutTx()
run synchronously.
Although the above fix is sufficient for this specific issue, we find it important to note that the code here is multithreaded and accesses ob.submittedTx[]
asynchronously without any locking involved. Additionally, ob.submittedTx[]
is often out of sync with the tracker in the x/crosschain
module. Code like this is prone to similar bugs, and it is especially prone to bugs being introduced in the future. Because of this, it is our recommendation that the ZetaChain team do a thorough refactoring of the code to introduce synchronization between the functions. This would eliminate the racy nature of the code and make it less likely for bugs to be introduced in the future.
Remediation
This issue has been acknowledged by ZetaChain.