Reproduced Exploit

One distributeFor pays 500 BGT because the block increments too late

distributeFor checks that the block is still actionable, sends 100 BGT to the validator, then calls the incentive token, and only then increments nextBlock. The incentive token calls distributeFor again while nextBlock is still 1. The pool holds 500 BGT, so the same block pays 100 BGT five times. T…

Oct 2024•Ethereum•reentrancy•4 min read

Loss

Validator receives 500 BGT from one block. Control pays 100 BGT once

Chain

Ethereum

Category

reentrancy

Date

Oct 2024

Source

AuditVault

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.

Loading fork state…

Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 52869-distributefor-might-be-reentered-leading-to-the-loss-of-bgt. Standalone Foundry PoC and full write-up: 52869-distributefor-might-be-reentered-leading-to-the-loss-of-bgt_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/reentrancy/single-function

Reproduction: self-contained synthetic Foundry reduction; see output.txt.

Key info#

FieldValue
LossValidator receives 500 BGT from one block. Control pays 100 BGT once
Vulnerable contractDistributor.distributeFor
Attacker EOA0x1111111111111111111111111111111111111111
Attack contractExploit
Attack txExploit.run()
Chain / block / dateEthereum model · block 0 · 2024-10
Compilersolc 0.8.27, cancun, via IR
Bug classIncentive transfer calls back before nextBlock increments

TL;DR#

distributeFor checks that the block is still actionable, sends 100 BGT to the validator, then calls the incentive token, and only then increments nextBlock. The incentive token calls distributeFor again while nextBlock is still 1. The pool holds 500 BGT, so the same block pays 100 BGT five times. The validator's balance is 500 BGT. The control distributor increments nextBlock before the external call and pays 100 BGT once.

Background#

Spearbit reviewed Berachain POL in October 2024. Distributor.distributeFor pays BGT and vault incentive tokens. The report says the incentive transfer is an untrusted external call placed before getNextActionableBlock is incremented, so a callback can re-enter and drain the BGT allocated for that block. The parameters of the outer call are visible to a frontrunner. This reduction arms one incentive token whose transfer calls back into the distributor that sent it.

The vulnerable code#

SOLIDITY
function distributeFor() external {
    require(nextBlock == 1, "block");
    require(bgt.balanceOf(address(this)) >= PAY, "bgt");
    require(bgt.transfer(validator, PAY), "pay");
    // @> VULN: incentive transfer happens before nextBlock is incremented
    require(hook.transfer(validator, PAY), "incentive");
    nextBlock += 1;
}

PAY is 100 BGT. The hook re-enters only while the buggy distributor still holds at least 100 BGT. The fixed distributor increments nextBlock before either transfer. Its hook call does not re-enter, because the hook is armed only on the buggy distributor.

Root cause#

The effect that stops a second payment is written after the interaction. Every nested call still observes nextBlock == 1 and passes the check. The increments run on the way out, after the BGT has already moved.

Preconditions#

  • The distributor holds 500 BGT. Each successful entry pays 100 BGT.
  • The incentive token calls back into this distributor when it is the caller and the remaining BGT covers another payment.
  • The control distributor holds the same 500 BGT and increments first.
  • One transaction. No warp.

Attack walkthrough#

  1. The validator's BGT balance is 500000000000000000000 (output.txt:9).
  2. That balance is 5 payments of 100 (output.txt:11).
  3. The control validator receives 100000000000000000000 (output.txt:13).
  4. [PASS] (output.txt:6).

Diagrams#

flowchart TD Enter["distributeFor sees nextBlock still 1"] --> Pay["Send 100 BGT"] Pay --> Hook["Incentive token calls back"] Hook --> Again["Same check passes four more times"] Again --> Done["Validator holds 500 BGT"] Done --> Control["Control increments first and pays 100"]

Impact#

The validator receives 500 BGT for a block that should have paid 100 BGT. The pool is empty. The control path pays 100 BGT and leaves 400 BGT on the distributor. The extra 400 BGT is the re-entered payments.

Remediation#

Increment the actionable block before the BGT transfer and the incentive transfer, or guard distributeFor against re-entry.

DIFF
     require(bgt.balanceOf(address(this)) >= PAY, "bgt");
+    nextBlock += 1;
     require(bgt.transfer(validator, PAY), "pay");
     require(hook.transfer(validator, PAY), "incentive");
-    nextBlock += 1;

How to reproduce#

BASH
cd 52869-distributefor-might-be-reentered-leading-to-the-loss-of-bgt_exp
forge test -vv

Sources#


Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

  • Web3Sec X hacked database: search.
  • Rekt leaderboard: search.
  • Solodit incident search: search.

These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.