Logo

Tachyon: Saving $600M from a time-warp attack

· January 28, 2026 · 9 min read

One day, our prototype flagged something odd: a consensus-critical timestamp issue in [redacted]. It turned out to be a 6-year-old critical bug affecting 40+ chains, hiding in plain sight. We spun up a local testnet and wrote an exploit. It worked, with $600M+ asset theft demonstration.

0Abstract

We found a critical vulnerability in CometBFT that enables a single malicious validator to arbitrarily increase block timestamps, leading to either:

  1. Unbounded minting of native tokens on chains with staking (Babylon, Celestia), allowing 100% of the chains’ whole TVL to be extorted, and/or
  2. Permanent network halt (40+ chains)

This issue affects all versions of CometBFT and was patched on January 21, 2026.

We encountered this bug while running our bug-hunting and exploitation tool on Celestia, which produced proof-of-concept results. After a brief triage, we determined that the exploited logic was not in Celestia’s application layer, but in the CometBFT consensus logic, and quickly realized that the exploit could apply to most (40+) Cosmos-based chains. While investigating them, we further observed that this issue could be escalated into direct asset theft by abusing staking reward mechanics.


1Part 1: Technical background

1.1BFT time in CometBFT

CometBFT implements Byzantine Fault Tolerant (BFT) time to ensure that block timestamps are derived from a weighted median of validator votes, preventing any single faulty validator from arbitrarily manipulating time.

The key guarantee is that:

Block time is computed as a weighted median of precommit timestamps, where each validator’s timestamp is weighted by their voting power.

This should ensure that even with up to 1/3 Byzantine voting power, the median timestamp remains within bounds controlled by honest validators.

1.2Commit and commit signatures

type Commit struct {
// NOTE: The signatures are in order of address to preserve the bonded
// ValidatorSet order.
// Any peer with a block can gossip signatures by index with a peer without
// recalculating the active ValidatorSet.
Height int64 `json:"height"`
Round int32 `json:"round"`
BlockID BlockID `json:"block_id"`
Signatures []CommitSig `json:"signatures"`
// Memoized in first call to corresponding method.
// NOTE: can't memoize in constructor because constructor isn't used for
// unmarshaling.
hash cmtbytes.HexBytes
}
type CommitSig struct {
BlockIDFlag BlockIDFlag `json:"block_id_flag"`
ValidatorAddress Address `json:"validator_address"`
Timestamp time.Time `json:"timestamp"`
Signature []byte `json:"signature"`
}

In CometBFT, a Commit is the finalized proof that a block was accepted, bundling the block ID with a set of validator Commit Signatures. Each commit signature is a validator’s signed vote (with a timestamp and voting power) attesting to that block, and together they prove that >2/3 of the validator set agreed on the block.


2Part 2: The bug

In short, the bug manifests in two different places:

  1. Commit signature verification uses the validator index
  2. Median time computation uses the validator address and skips the validator if the address is not present in the current validator set

The following code snippet is an abridged version of the vulnerable functions, with the vulnerable branches being [1] and [2]:

types/validation.go
func verifyCommitBatch(/* .. */){:go}
for idx, commitSig := range commit.Signatures {
if ignoreSig(commitSig) {
continue
}
if lookUpByIndex {
val = vals.Validators[idx] /* [1] */
}
voteSignBytes := commit.VoteSignBytes(chainID, int32(idx))
if err := bv.Add(val.PubKey, voteSignBytes, commitSig.Signature); err != nil {
return err
}
}
}
state/state.go
func MedianTime(commit *types.Commit, validators *types.ValidatorSet) time.Time {
weightedTimes := make([]*cmttime.WeightedTime, len(commit.Signatures))
totalVotingPower := int64(0)
for i, commitSig := range commit.Signatures {
if commitSig.BlockIDFlag == types.BlockIDFlagAbsent {
continue
}
_, validator := validators.GetByAddress(commitSig.ValidatorAddress)
if validator != nil { /* [2] */
totalVotingPower += validator.VotingPower
weightedTimes[i] = cmttime.NewWeightedTime(commitSig.Timestamp, validator.VotingPower)
}
}
return cmttime.WeightedMedian(weightedTimes, totalVotingPower)
}

2.1Commit verification: index-based lookup

When verifying a block’s commit signatures, CometBFT uses verifyCommitBatch (or verifyCommitSingle) with lookUpByIndex=true.

The validator is identified solely by index, meaning that the commitSig.ValidatorAddress field is not verified against val.Address. Since lookUpByIndex is always true in normal commit verification (VerifyCommit()verifyCommitBatch(..., lookUpByIndex = true)), every block takes the vulnerable path.

2.2MedianTime: address-based lookup

In contrast, when computing block time, MedianTime uses address-based lookup.


3Part 3: Exploitation

Using these two oversights, an attacker can supply a malicious Commit struct with the following data:

Commit = {
Signatures: [
{ ValidatorAddress: 0xBaDBaDBaD..., Signature: <original signature>... },
{ ValidatorAddress: 0xBaDBaDBaD..., Signature: <original signature>... },
/* repeat until we reach attacker's validator index */
{ ValidatorAddress: "<attacker's valid address>", Signature: "<attacker's original signature>" }
/* populate the rest with incorrect CommitSig items */
{ ValidatorAddress: 0xBaDBaDBaD..., Signature: <original signature>... },
]
/* populate other fields appropriately */
}

Assuming the attacker’s validator index is kk, for all iki \neq k the validator lookup evaluates to nil, causing those entries to be skipped and contributing nothing to weightedTimes. For i=ki = k, weightedTimes[i] is set to cmttime.NewWeightedTime(commitSig.Timestamp, validator.VotingPower).

The function then computes the weighted median over weightedTimes, which collapses to commitSig.Timestamp since all other entries have zero weight. In effect, the attacker gains full control over the median timestamp by invalidating all other contributions.

state/state.go
for i, commitSig := range commit.Signatures {
_, validator := validators.GetByAddress(commitSig.ValidatorAddress)
if validator != nil {
totalVotingPower += validator.VotingPower
weightedTimes[i] = cmttime.NewWeightedTime(commitSig.Timestamp, validator.VotingPower)
}
// No error raised if validator is nil - silently skipped!
}

3.1Time warp and beyond

The immediate effect of this exploit grants the attacker the ability to advance the timestamp by arbitrary amounts. In an example local demo, we were capable of advancing the block time to 9999-12-31 23:59:59.

Height Proposer Block Time
1 6658793E23AA3A1A3CB68FCD0CF4E14C299322B7 2026-01-07T21:51:01Z
2 921265369C1BC15053183324643C284CCC2204F9 2026-01-07T21:51:02Z
3 9F18146AD2BF25510208D13D4DDAED7DDFA029D1 9999-12-31T23:59:59Z ← MALICIOUS
4 F847FCD43163C15C96C0EBD6B88CAF89F2CF1BD6 9999-12-31T23:59:59.001Z
5 6658793E23AA3A1A3CB68FCD0CF4E14C299322B7 9999-12-31T23:59:59.002Z

This value is 1 second behind from the maximum timestamp allowed in CometBFT, causing the chain to halt due to an assertion after a single round. Furthermore, this is exacerbated by CometBFT’s monotonic time enforcement, making the damage irrecoverable.

timestamp: &timestamppb.Timestamp{Seconds: 253402300800, Nanos: 999999} after 10000-01-01
goroutine 242 [running]:
runtime/debug.Stack()
runtime/debug/stack.go:26 +0x5e
github.com/cometbft/cometbft/consensus.(*State).receiveRoutine.func2()
github.com/cometbft/cometbft/consensus/state.go:799 +0x46
panic({0x16b03c0?, 0xc0003c0850?})
runtime/panic.go:783 +0x132
github.com/cometbft/cometbft/types.VoteSignBytes({0xc000012f40, 0xc}, 0xc001d9a820)
github.com/cometbft/cometbft/types/vote.go:152 +0x18f
...

3.2Asset theft

However, we realized that this bug has far more severe implications than merely halting the network (serious as that already is). It can be exploited to mint an unbounded amount of native tokens, enabling direct asset theft. To our knowledge, at least two major chains compute staking rewards on a block timestamp basis; advancing the timestamp by hundreds or thousands of years would therefore cause immediate and extreme inflation of the native token supply.

Example

For example, by manipulating the timestamp 200 years into the future, a chain with an initial (genesis) supply of 4,002,000,000,000 tokens could see its supply balloon to 47,994,771,968,060 tokens, resulting in an inflation multiplier of approximately 1,199%.

An attacker could then exit the artificially inflated tokens (e.g., via a CEX), draining liquidity from the chain.


4Part 4: Proof of concept

We were able to construct the same PoC across the affected chains’ devnet configurations. In an N-validator setup with a single attacker-controlled node that emits blocks with forged Timestamp and ValidatorAddress values, we show that a single validator that is given at least one opportunity to propose a block can force a chain-wide timestamp jump without reaching a 1/3 or 2/3 quorum.

We will defer the disclosure of the PoC in consideration of chains that may not yet have been able to address the issue in a timely manner.

4.1PoC setup & observed results

Output 1: Timestamp manipulation
./watch_time_jump.sh <http://localhost:26657> 600
=== TIME JUMP DETECTED ===
height: 23
time: 2026-01-08T01:49:39.847205037Z
prevtime: 2026-01-08T00:49:39.846205037Z
delta_s: 3600
proposer: 4CA5AC0B75FA23B16F1D308247D480FEEC4BB936
=== TIME JUMP DETECTED ===
height: 27
time: 2026-01-08T02:49:39.851205037Z
prevtime: 2026-01-08T01:49:39.850205037Z
delta_s: 3600
proposer: 4CA5AC0B75FA23B16F1D308247D480FEEC4BB936
=== TIME JUMP DETECTED ===
height: 31
time: 2026-01-08T03:49:39.855205037Z
prevtime: 2026-01-08T02:49:39.854205037Z
delta_s: 3600
proposer: 4CA5AC0B75FA23B16F1D308247D480FEEC4BB936
=== TIME JUMP DETECTED ===
height: 35
time: 2026-01-08T04:49:39.859205037Z
prevtime: 2026-01-08T03:49:39.858205037Z
delta_s: 3600
proposer: 4CA5AC0B75FA23B16F1D308247D480FEEC4BB936

Every time the malicious validator (4CA5…) proposes, the block timestamp is incremented by exactly 3600 seconds.

Output 2: Asset theft
Time | Height | Total Supply | Minted This Block
------------------------------|--------|---------------------|-------------------
2041-01-25T08:57:02.721714914Z | 10 | 135,980,315,819,112 | +43992771925508
2041-01-25T08:57:02.721714914Z | 10 | 135,980,315,819,112 | +0
2041-01-25T08:57:02.722714914Z | 11 | 135,980,315,819,118 | +6
2041-01-25T08:57:02.723714914Z | 12 | 135,980,315,819,124 | +6
2041-01-25T08:57:02.724714914Z | 13 | 135,980,315,819,130 | +6
2240-12-08T08:57:02.725714914Z | 14 | 179,973,087,744,638 | +43992771925508
2240-12-08T08:57:02.726714914Z | 15 | 179,973,087,744,644 | +6
2240-12-08T08:57:02.727714914Z | 16 | 179,973,087,744,650 | +6
2240-12-08T08:57:02.728714914Z | 17 | 179,973,087,744,656 | +6

Each time the timestamp is manipulated, the validator’s staking reward balloons to the extent that it becomes un-bondable.


5Part 5: Fixes

This bug can be fixed by adding an equality check in verifyCommitBatch when lookUpByIndex=true:

if lookUpByIndex {
val = vals.Validators[idx]
if !bytes.Equal(val.Address, commitSig.ValidatorAddress) {
return fmt.Errorf("validator address mismatch at index %d: expected %X, got %X",
idx, val.Address, commitSig.ValidatorAddress)
}
}

Additionally, MedianTime should return an error when encountering unknown validator addresses rather than silently skipping them:

_, validator := validators.GetByAddress(commitSig.ValidatorAddress)
if validator == nil {
return time.Time{}, fmt.Errorf("unknown validator address in commit: %X",
commitSig.ValidatorAddress)
}

To summarize,

  • verifyCommitBatch ignores ValidatorAddress: Add address equality check when lookUpByIndex=true
  • MedianTime silently skips unknown addresses: Return error for unknown addresses

6Part 6: Timeline

The first vulnerable commit was introduced in ad715fe9 in November 2019 (Release: Tendermint v0.33.0), which suggests that this bug underwent 6 years worth of bug bounty programs and audits.

The commit changed the Commit structure from Precommits (containing ValidatorIndex) to Signatures (containing ValidatorAddress) as part of ADR-25 to reduce block size.

Pre-commit: Index-based lookup
func MedianTime(commit *types.Commit, validators *types.ValidatorSet) time.Time {
for i, vote := range commit.Precommits {
if vote != nil {
_, validator := validators.GetByIndex(vote.ValidatorIndex)
totalVotingPower += validator.VotingPower
weightedTimes[i] = tmtime.NewWeightedTime(vote.Timestamp, validator.VotingPower)
}
}
return tmtime.WeightedMedian(weightedTimes, totalVotingPower)
}
Post-commit: Address-based lookup
func MedianTime(commit *types.Commit, validators *types.ValidatorSet) time.Time {
for i, commitSig := range commit.Signatures {
if commitSig.Absent() {
continue
}
_, validator := validators.GetByAddress(commitSig.ValidatorAddress)
if validator != nil { // SILENTLY SKIPS UNKNOWN ADDRESSES!
totalVotingPower += validator.VotingPower
weightedTimes[i] = tmtime.NewWeightedTime(commitSig.Timestamp, validator.VotingPower)
}
}
return tmtime.WeightedMedian(weightedTimes, totalVotingPower)
}

Consequently, MedianTime was changed to use an address-based validator lookup (GetByAddress), silently skipping validators whose addresses are not present in the validator set instead of propagating an error. Meanwhile, the commit verification logic continued to rely on index-based lookup (GetByIndex), resulting in the exploitable inconsistency.

6.1Timeline

DateEvent
November 26, 2019Vulnerability introduced (Tendermint commit changed MedianTime to use address-based lookup)
January 15, 2020Tendermint v0.33.0 released (first vulnerable release)
February 2023CometBFT forked Tendermint and inherited the vulnerability
January 7, 2026Vulnerability discovered and reported to CometBFT via SEAL 911
January 7, 2026War room created
January 9, 2026CometBFT acknowledged vulnerability
January 13, 2026CometBFT securely distributed patches to partners
January 21, 2026Public announcement

7References


8Who are we?

QED Audit is a blockchain security firm delivering AI-powered audits.

In 2026, relying only on manual audits is no longer practical. Large codebases, such as blockchains built on the Cosmos SDK, are growing and changing at a pace manual security reviews cannot keep up with. This is reflected in the fact that this critical bug went unnoticed for six years.

At QED Audit, we provide continuous, automated analysis without sacrificing the depth of human review. We achieve this by combining AI agents, traditional analysis techniques, and deep blockchain security expertise. Contact us to see whether our automated researchers can uncover issues hiding in your codebase.