Obedient Wool Canary
Medium
Adversary can force all vigilante nodes to waste tx fees, for bad checkpoints being submitted to the babylon chain
Checkpoints are not 100% validated when retrieved from BTC blocks from the vigilante nodes, since it tries to decode all txs with OP_RETURN
data, this opens an attack vector, where adversary with 1 op_return tx
with semi-valid checkpoint submitted to BTC, can force all vigilante nodes to spend unnecessary tx fees for submitting InsertBTCSpvProof
tx to babylon chain.
( This is a concerning griefing attack as 1 TX of an attacker
= many TXs from vigilante nodes
)
Reference to the reporter docs: https://docs.babylonlabs.io/docs/developer-guides/vigilantes/reporter
Currently vigilante reporter module
:
- Scans for all checkpoints in a BTC block that have the valid structure, without verifying submitter.
- Does not perform full validation on the checkpoint for its full validity, i.e. that its not an old checkpoint
- Creates merkle proof that the transaction exists/is retrieved from a bitcoin blockchain block, but still doesnt do full validation for the checkpoint itself.
First we do the extraction via extractCheckpoints(), where we process checkpoints from Bitcoin network
func (r *Reporter) extractCheckpoints(ib *types.IndexedBlock) int {
// for each tx, try to extract a ckpt segment from it.
// If there is a ckpt segment, cache it to ckptCache locally
numCkptSegs := 0
for _, tx := range ib.Txs {
if tx == nil {
r.logger.Warnf("Found a nil tx in block %v", ib.BlockHash())
continue
}
// cache the segment to ckptCache
@>> ckptSeg := types.NewCkptSegment(r.CheckpointCache.Tag, r.CheckpointCache.Version, ib, tx)
@>> if ckptSeg != nil {
r.logger.Infof("Found a checkpoint segment in tx %v with index %d: %v", tx.Hash(), ckptSeg.Index, ckptSeg.Data)
@>> if err := r.CheckpointCache.AddSegment(ckptSeg); err != nil {
r.logger.Errorf("Failed to add the ckpt segment in tx %v to the ckptCache: %v", tx.Hash(), err)
continue
}
numCkptSegs++
}
}
return numCkptSegs
}
Then matchAndSubmitCheckpoints()
is called in ProcessCheckpoints()
, to match the 2 parts of the checkpoints and submit them to the Babylon chain via r.babylonClient.InsertBTCSpvProof(context.Background(), msgInsertBTCSpvProof)
call, where each checkpoint has a separate tx.
func (r *Reporter) matchAndSubmitCheckpoints(signer string) int {
var (
proofs []*btcctypes.BTCSpvProof
msgInsertBTCSpvProof *btcctypes.MsgInsertBTCSpvProof
)
// get matched ckpt parts from the ckptCache
// Note that Match() has ensured the checkpoints are always ordered by epoch number
r.CheckpointCache.Match()
numMatchedCkpts := r.CheckpointCache.NumCheckpoints()
if numMatchedCkpts == 0 {
r.logger.Debug("Found no matched pair of checkpoint segments in this match attempt")
return numMatchedCkpts
}
// for each matched checkpoint, wrap to MsgInsertBTCSpvProof and send to Babylon
// Note that this is a while loop that keeps popping checkpoints in the cache
@>> for {
// pop the earliest checkpoint
// if popping a nil checkpoint, then all checkpoints are popped, break the for loop
ckpt := r.CheckpointCache.PopEarliestCheckpoint()
if ckpt == nil {
break
}
r.logger.Info("Found a matched pair of checkpoint segments!")
// fetch the first checkpoint in cache and construct spv proof
proofs = ckpt.MustGenSPVProofs()
// wrap to MsgInsertBTCSpvProof
//@audit-issue txs for proof are submitted spearately which just costs more gas for vigilante node
msgInsertBTCSpvProof = types.MustNewMsgInsertBTCSpvProof(signer, proofs)
// submit the checkpoint to Babylon
res, err := r.babylonClient.InsertBTCSpvProof(context.Background(), msgInsertBTCSpvProof)
if err != nil {
r.logger.Errorf("Failed to submit MsgInsertBTCSpvProof with error %v", err)
r.metrics.FailedCheckpointsCounter.Inc()
continue
}
r.logger.Infof("Successfully submitted MsgInsertBTCSpvProof with response %d", res.Code)
r.metrics.SuccessfulCheckpointsCounter.Inc()
r.metrics.SecondsSinceLastCheckpointGauge.Set(0)
tx1Block := ckpt.Segments[0].AssocBlock
tx2Block := ckpt.Segments[1].AssocBlock
r.metrics.NewReportedCheckpointGaugeVec.WithLabelValues(
strconv.FormatUint(ckpt.Epoch, 10),
strconv.Itoa(int(tx1Block.Height)),
tx1Block.Txs[ckpt.Segments[0].TxIdx].Hash().String(),
tx2Block.Txs[ckpt.Segments[1].TxIdx].Hash().String(),
).SetToCurrentTime()
}
return numMatchedCkpts
}
Adversary can easily submit checkpoint txs to Bitcoin, which have proper encoding, but are not really valid, or are old valid checkpoints for example ( or already submitted one ). And when it reaches the InsertBTCSpvProof function in msg_server.go on the Babylon chain, it will fail after all the proper validation is performed and resources would be wasted:
Reference to func (ms msgServer) InsertBTCSpvProof(ctx context.Context, req *types.MsgInsertBTCSpvProof)
: https://github.com/babylonlabs-io/babylon/blob/b9a8cc8644da89e5c14906228553ace7554180cb/x/btccheckpoint/keeper/msg_server.go#L28
- Adversary submits semi-valid checkpoint, so it will be propagated from vigilante nodes to the babylon chain.
- Vigilante nodes after k-deep blocks process this checkpoint and submit it to the Babylon chain.
- After validation on babylon the checkpoint submission fails, but fees are still wasted.
- Adversary repeats the operation.
1 TX of an attacker
= many TXs from vigilante nodes and wasted tx fees
. - There is an incentive for the griefer, as the actual impact is larger, than the input required. OP_RETURN
txs are also very cheap and since attacker, does not need fee priority for the tx it would cost him less than <0.1$ per tx. When there are many vigilante nodes in the babylon chain network, then their cost will easily highly exceed way more than the attacker's cost.
For example, if we have 50 vigilantes, fee cost of all vigilantes submitting the txs could exceed 2-3$. Which allows for huge ratios like 1:30, meaning that for every dollar the griefer spends, the vigilantes in total spend 30$.
Add further validation before submitting tx to babylon, ensuring checkpoints are good. Add additional checks to query from Babylon for additional info & etc.