Welcome to The Notorious Bug Digest #7, a curated compilation of insights into recent Web3 bugs and security incidents. When our security researchers aren’t diving into audits, they dedicate time to staying up-to-date with the latest in the security space, analyzing audit reports, and dissecting on-chain incidents. We believe this knowledge is invaluable to the broader security community, offering a resource for researchers to sharpen their skills and helping newcomers navigate the world of Web3 security. Join us as we explore this batch of bugs together!

Bug Analysis: How a Silent Skip in BFT Time Calculation Allowed Timestamp Manipulation

On February 2, 2026, a critical consensus-level vulnerability dubbed "Tachyon" (GHSA-c32p-wcqj-j677) was disclosed in CometBFT, the consensus engine powering the Cosmos ecosystem. This vulnerability allowed a malicious proposer to manipulate block timestamps, potentially disrupting time-sensitive applications.

In CometBFT, block time is not simply the proposer's local clock. Instead, it is calculated deterministically as the weighted median of the timestamps provided by validators in the LastCommit of the previous block. This mechanism, known as "BFT Time," ensures that a malicious minority cannot shift the blockchain's time.

When a block is proposed, other nodes verify it by checking the LastCommit. This involves two distinct operations:

  • Signature Verification: Ensuring that more than 2/3 of the voting power signed the block.
  • Time Verification: Recalculating the weighted median time to ensure that it matches the block's header time.

Index vs. Address: The core issue stemmed from an inconsistency in how validators were identified during these two operations.

1. Signature Verification (Index-Based)

For efficiency, signature verification relies on the validator's index in the validator set. In the vulnerable code, the verification logic fetched the validator by the index without verifying whether the ValidatorAddress field in the commit signature (CommitSig) matched the validator. A signature covers the BlockID, Height, Round, and Timestamp, but not the ValidatorAddress field.

// types/validation.go (simplified)
if lookUpByIndex {
    val = vals.Validators[idx] // Gets validator by array index
    // VULNERABILITY: No check that val.Address == commitSig.ValidatorAddress

    // Verifies signature against val.PubKey. Success!
    if !val.PubKey.VerifySignature(voteSignBytes, commitSig.Signature) {
        return fmt.Errorf("wrong signature...")
    }
}

2. Time Calculation (Address-Based)

Conversely, the MedianTime function used to calculate the block time looked up validators by the commit's ValidatorAddress. If a validator was not found for a given address, the code silently skipped that signature instead of returning an error.

// state/state.go (simplified)
func MedianTime(commit *types.Commit, validators *types.ValidatorSet) time.Time {
    for i, commitSig := range commit.Signatures {
        // ...
        _, validator := validators.GetByAddress(commitSig.ValidatorAddress)

        // VULNERABILITY: If validator is nil (not found), we silently skip!
        if validator != nil {
            totalVotingPower += validator.VotingPower
            weightedTimes[i] = cmttime.NewWeightedTime(commitSig.Timestamp, validator.VotingPower)
        }
    }
    return cmttime.WeightedMedian(weightedTimes, totalVotingPower)
}

The attacker, a malicious validator, can propose a block with valid signatures and replace the ValidatorAddress with a fake one. The signature verification (using the index) passes, but the time calculation (using the address) fails to find the validator and silently skips it. By selectively "muting" specific validators, the attacker can skew the weighted median calculation to a value of their choosing.

To fix this, CometBFT v0.38.21 introduced strict address validation during signature verification (ensuring that the index matches the address) and updated MedianTime to return an error instead of silently skipping missing validators.

This highlights that consistency in data validation is critical. When multiple subsystems (verification vs. calculation) rely on the same data structure, they must use the same identification methods and validation strictness. Furthermore, silent failures (like skipping a missing validator without error) often hide logic bugs that can be exploited.

Please refer to the following write-up for an overview of possible exploitation vectors: Tachyon: Saving $600M from a Time-Warp Attack.

Bug Analysis: Value Semantics Silently Broke a Reentrancy Lock in Gnoswap

This bug was identified during a Gnoswap audit performed by OpenZeppelin.

Gnoswap is a decentralized exchange built on Gno, a Go-derived smart-contract language. Like Uniswap V3, Gnoswap's Swap function protects against reentrancy using a boolean unlocked flag inside the Slot0 struct. The intended pattern is: read Slot0, set unlocked = false before any external callback, execute the swap, and restore unlocked = true via defer. Any reentrant call would see the flag and panic.

The pool exposes Slot0 through a getter that returns the struct by value:

// pool.gno
func (p *Pool) Slot0() Slot0 { return p.slot0 }  // returns a copy, not a pointer

In Go (and Gno), returning a struct by value means that the caller receives an independent copy. The Swap function used this getter in the following manner:

// swap.gno (before fix)
slot0Start := pool.Slot0()       // slot0Start is a COPY of pool.slot0
if !slot0Start.Unlocked() {
    panic(errLockedPool)
}

slot0Start.SetUnlocked(false)    // mutates the local copy only
                                 // pool.slot0.unlocked is still true!

// ... swap computation ...

// External callback — reentrancy is possible here
i.safeSwapCallback(pool, tokenPath, amount, zeroForOne, swapCallback)

defer func() {
    slot0Start.SetUnlocked(true) // again, only the local copy
}()

Since pool.Slot0() returns a value, slot0Start is an independent copy. As such, calling slot0Start.SetUnlocked(false) only modifies this local variable, while the pool's actual slot0.unlocked field remains true. The reentrancy guard never takes effect. Any re-entrant call into Swap reads pool.Slot0().Unlocked(), sees true, and passes the check without issue.

With the guard disabled, swapCallback becomes an unguarded entry point. After the callback returns, safeSwapCallback checks that the pool's token balance increased by the expected amount as a safety net. However, since reentrancy is possible, an attacker could invoke other pool or position functions (such as IncreaseLiquidity) that deposit tokens into the pool within the callback. These deposits inflate the balance, causing the post-callback check to pass even without the attacker making the intended swap repayment, effectively stealing value from the pool.

In languages with value semantics like Go and Gno, mutation-dependent patterns like reentrancy guards can fail silently. When a getter returns a struct by value, any mutations to the returned copy are lost unless explicitly written back. Auditors reviewing Go or Gno smart contracts should pay close attention to whether state-modifying operations act on the actual stored state or on ephemeral copies.

Bug Analysis: Agent Vault Hijacking via Stale Delegation Links in FAsset System

This bug was identified during a Flare FAssets audit performed by OpenZeppelin.

The FAsset system is a core component of the Flare Network that enables trustless issuance of assets from other chains (e.g., BTC, XRP) onto Flare as FAssets. Agents lock native collateral (such as FLR or SGB) in Agent Vaults and earn fees in return.

The Agent Delegation Mechanism

The AgentOwnerRegistry contract supports a two-tier permission model for operational security:

  • Management Address: The vault owner, whitelisted by governance, with full privileges including withdrawal of collateral.
  • Work Address: A delegated address with limited permissions (e.g., adding collateral) for day-to-day operations.

Two mappings implement this mechanism: workToMgmtAddress (work → management) and mgmtToWorkAddress (management → work). A whitelisted agent links a work address via setWorkAddress function. Importantly, that function blocks already whitelisted addresses from being set as work addresses. Otherwise, _getManagementAddress, used at vault creation, would resolve the work address to its manager and misattribute vault ownership. The rule is: a whitelisted agent must always be treated as a top-level owner, not a subordinate.

set-work-address-1

The bug is a lifecycle oversight: setWorkAddress only enforces the “no whitelisted work address” rule at delegation time. When the status of an address changes later, the protocol does not re-validate or clear existing links. If an address that is already registered as a work address is later whitelisted by governance, the old delegation is left in place. That stale workToMgmtAddress entry can then be abused to hijack the new agent’s vault.

The chain of failure starts in whitelistAndDescribeAgent. This governance function adds new agents but does not check whether the candidate address already appears in workToMgmtAddress. Whitelisting proceeds without clearing or invalidating any existing work-address link, so a pre-existing delegation remains active.

white-list-functions

When the victim (now whitelisted) calls createAgentVault, the contract uses _getManagementAddress to resolve ownership. That helper trusts workToMgmtAddress. Since the victim was previously bound as a work address, they are resolved to the attacker’s management address. The vault is therefore created under the attacker’s control and passes the whitelist check.

getManagementAddress-function
create-agent-vault

 

An attack exploiting this bug would proceed as follows:

  1. Attacker A (already whitelisted) binds victim B (not whitelisted) as A’s work address via setWorkAddress(B). This succeeds because B is not yet whitelisted. A can also front-run governance’s upcoming whitelisting of B by monitoring the mempool and sending setWorkAddress(B) just before the whitelisting transaction executes, ensuring that B is non-whitelisted at A’s call time while locking in the work→management link.
  2. Governance later whitelists B using whitelistAndDescribeAgent, which calls the internal _addAddressToWhitelist but does not validate or clear existing workToMgmtAddress entries. The legacy mapping persists.
  3. When B creates an agent vault, ownership resolution maps B → A in _getManagementAddress and the whitelist requirement in createAgentVault is satisfied by A. At this point, the vault’s management address is A, with B as A’s work address, allowing B to add collateral while enabling A to steal it.

The lesson is that access-control checks must be lifecycle-aware. Validating state only at a single point in time (e.g., at delegation) is insufficient when entities can later change role (e.g., from work address to whitelisted agent). Hence, security must account for state transitions and either re-validate or invalidate dependent state.

The fix was to introduce an explicit opt-in for work-address delegation: the nominated work address must accept the link before it becomes active. This removes the ability to bind an address without its consent and closes the hijacking vector while keeping whitelisting and delegation usable.

Incident Analysis: Improper Authorization Check Allowed Hostile Takeover in Taraxa Bridge

The Taraxa Bridge smart contracts, which enable asset transfers between Ethereum and the Taraxa chain, were exploited on February 22, 2026, resulting in the loss of approximately $13K worth of ETH, USDT, and TARA tokens. The exploit was caused by an improper authorization check that relied on state mutated earlier within the same transaction, allowing the attacker to subvert the validator-based quorum threshold.

The bridge architecture relied on the TaraClient contract to finalize Taraxa blocks on Ethereum. This process involved verifying validator signatures, ensuring that the cumulative validator weight met a predefined weightThreshold, and then storing the approved bridge root in the finalizedBridgeRoots mapping via the finalizeBlocks function. The Bridge contract later consumed these finalized roots via applyState function to execute corresponding state changes on Ethereum, such as releasing bridged assets.

The vulnerability stemmed from the fact that TaraClient applied validator updates through processValidatorChanges before verifying whether the validator quorum met the required weightThreshold. Specifically, the contract updated validator voteCount and totalWeights using calldata supplied in the same transaction that attempted block finalization. These newly mutated weights were then immediately used for the authorization check.

taraXa-finalizeBlocks

 

By crafting malicious validator updates, the attacker artificially inflated the weight of a validator under their control. Since the quorum check was performed against this modified state, the attacker was able to satisfy the weightThreshold without possessing legitimate validator consensus. This allowed the attacker to finalize a malicious bridge root and store it in finalizedBridgeRoots.

taraXa-processValidatorChanges

 

With a fraudulent bridge root now considered valid, the attacker invoked Bridge.applyState, which trusted the root and executed the encoded state transitions. As a result, the bridge released its escrowed assets, transferring the ETH, USDT, and TARA tokens directly to the attacker.

This incident underscores a critical smart contract design flaw: authorization and consensus checks must never depend on state that can be modified earlier in the same call context. Validator set updates and quorum verification must be strictly separated or staged across transactions to prevent attackers from manipulating trust assumptions mid-execution.

Disclaimer

It is important to emphasize that the intent behind this content is not to criticize or blame the affected projects. Instead, the goal is to provide objective analyses of the vulnerabilities that serve as educational material for the Web3 community to learn from and better protect itself in the future.