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…
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
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.
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-registrymirror.
Vulnerability classes: vuln/reentrancy/single-function
Reproduction: self-contained synthetic Foundry reduction; see output.txt.
Key info#
| Field | Value |
|---|---|
| Loss | Validator receives 500 BGT from one block. Control pays 100 BGT once |
| Vulnerable contract | Distributor.distributeFor |
| Attacker EOA | 0x1111111111111111111111111111111111111111 |
| Attack contract | Exploit |
| Attack tx | Exploit.run() |
| Chain / block / date | Ethereum model · block 0 · 2024-10 |
| Compiler | solc 0.8.27, cancun, via IR |
| Bug class | Incentive 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#
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#
- The validator's BGT balance is 500000000000000000000 (output.txt:9).
- That balance is 5 payments of 100 (output.txt:11).
- The control validator receives 100000000000000000000 (output.txt:13).
[PASS](output.txt:6).
Diagrams#
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.
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#
cd 52869-distributefor-might-be-reentered-leading-to-the-loss-of-bgt_exp
forge test -vv
Sources#
- AuditVault finding: 52869-distributefor-might-be-reentered-leading-to-the-loss-of-bgt
- Spearbit, Berachain POL, October 2024: https://github.com/spearbit/portfolio/blob/master/pdfs/Berachain-Pol-Spearbit-Security-Review-October-2024.pdf
Distributor.distributeForlines 113-114 in the report
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 52869-distributefor-might-be-reentered-leading-to-the-loss-of-bgt_exp (evm-hack-registry mirror).
- AuditVault finding: 52869-distributefor-might-be-reentered-leading-to-the-loss-of-bgt.
Alerts & third-party analyses
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.