Building an Interactive P2P Attack Gym in Rust
Slot pressure, fake state, and poisoned discovery on 127.0.0.1.
The previous gym post gave me terminal metrics: occupancy, honest_peers, state_diverged, and cross_peers_remaining. Those numbers were enough to compare runs, but not enough to see the sequence.
That sequence matters. A Sybil run can apply pressure without filling every slot. An Eclipse run can receive a fake Tip without diverging. A poisoned seed can bias the first peer list before the victim has formed a useful table.
This post adds p2p-viz, the interactive half of the gym in rust-p2p-protocol-lab. Pick a scenario, set the parameters, and run it from the left panel. The center graph replays the same p2p-env simulation: nodes appear, edges draw, peer slots fill. Result Summary on the right still prints the CLI metrics. No new protocol.
p2p-viz in the workspace
cargo run -p p2p-viz
# browser: http://127.0.0.1:3000/
The layout follows the gym: scenario buttons and parameter inputs on the left, a D3 topology in the center, Result Summary and Event Log on the right. Clicking a scenario posts your params to p2p-env and streams SimEvents over SSE; the graph updates step by step rather than jumping to the final screenshot.
The replay format is intentionally small: nodes, edges, packets, drops, and one terminal summary string.
The Rust boundary is the important part. The browser does not infer attack state from animation timing; it renders typed events emitted by the runner.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SimEvent {
NodeSpawned { id: String, addr: String, role: String },
ConnectionEstablished { from: String, to: String },
ConnectionDropped { from: String, to: String },
PacketSent { from: String, to: String, data: String },
PeerSlotsFull { node: String },
ScenarioComplete { scenario: String },
ScenarioReset,
}
That boundary matters for the screenshots. If the replay shows a surviving honest edge, it came from the same run that produced the terminal metric.
Replay 1: Sybil pressure is not capture
The GIF uses the Article preset with Defense on and ten Sybil identities.
The CLI said Success: false. The slot bar explains the result: three honest slots survived. This is still useful pressure, but it is not full capture.
Replay 2: Fake state is not enough
Eclipse adds state to the Sybil story. The GIF uses the Article preset with Defense off and twenty attackers. The attacker sends attacker_tip_FAKE; the metric only flips if the victim also loses every honest path.
let state_diverged = fake_tip_received && honest_peers == 0;
This is the point the UI should make: receiving bad state is not the same as being eclipsed. In this lab, Eclipse needs honest_peers == 0.
Replay 3: Bad discovery comes first
Nodes do not start with a full peer table. They first need a peer list from seeds, discovery tables, or another bootstrap path.
That first list is already a security boundary. If it is Sybil-heavy, the node starts from a biased network view before normal peer table competition begins.
Real networks have more structure than this toy seed. Bitcoin may use DNS seeds or known peers; Ethereum clients use discovery protocols and signed node records. The lab strips that down to one question: before peer table competition starts, who gets into the first peer set?
Bootstrap Poisoning moves the same cheap identity problem earlier. The Bootstrap panel uses eight Sybil identities. The GIF walks through the seed handshake: the victim asks a seed for peers and receives only Sybil addresses.
The metric is not slot occupancy. It is the first peer list:
let bootstrap_sybil_ratio = sybil_peers / total_bootstrap_peers;
cargo run -p p2p-env -- --honest 4 --attack bootstrap --sybil 8
# bootstrap_sybil_ratio: 100.0
# sybil_peers: 8.0
# honest_peers: 0.0
# total_bootstrap_peers: 8.0
This run is successful under the lab rule: bootstrap_sybil_ratio >= 50%.
Other panels in the gym
The left panel also exposes scenarios I am not treating as the three main replays:
- Partition:
cross_peers_remaining=0when two groups stop advertising each other. - TLS MITM Attack: Mallory remains in the path;
verified_peer=false; session intercepted. - Noise Handshake Defense: the relay attempt fails on remote key mismatch; the final session is direct and verified.
Note: Noise solves a different problem from Sybil or Bootstrap Poisoning. It can authenticate the remote transport key. It does not make identities expensive, diversify seed responses, or prevent peer table flooding.
Closing
The metrics did not change. The failure conditions became easier to inspect.
- Sybil pressure is visible as slot churn.
- Eclipse failure is visible as surviving honest edges.
- Bootstrap Poisoning is visible before the victim has a clean peer list.
That is the useful boundary for this UI. It does not prove real world exploitability. It shows which local condition made each lab metric flip, or why it did not.
Appendix
- Runs bind to
127.0.0.1. Clone rust-p2p-protocol-lab and start the UI:
git clone https://github.com/egpivo/rust-p2p-protocol-lab.git
cd rust-p2p-protocol-lab
cargo run -p p2p-viz
# browser: http://127.0.0.1:3000/
- Real world references and the original CLI gym are in the previous post.