Simulator scenario 2: ROA scope expansion and validation environment mapping¶
What this phase is about
After establishing baseline and legitimate RPKI presence (Playbook 1), Phase 2 prepares the control plane for an eventual hijack by:
Creating a fraudulent ROA — authorising your AS for a victim’s prefix and a more‑specific subprefix.
Testing where RPKI validation is actually enforced in the wider internet, so you know where your forthcoming attack will succeed.
Setting up monitoring for ROA changes — so you know if defenders revoke or override your fraudulent ROA.
Important real‑world nuance:
Many RPKI validators do incorporate conflicting ROAs, and deployment varies by region.
Phase 2 is mostly about observing the validation environment so you know if later hijacks will be accepted or dropped.
Simulator implementation¶
Below is a scenario you can drop into your simulator under something like:
simulator/scenarios/medium/playbook2/
It models the core actions:
Create fraudulent ROA for victim’s prefix with expanded maxLength
Send BGP test announcements to detect RPKI validation behavior
Produce periodic validation telemetry
It does not try to simulate RIR portal behaviour or real API polling, but produces telemetry you can use to exercise SIEM logic around phase 2.
Scenario¶
id: playbook2
name: "ROA Scope Expansion and Validation Mapping"
description: |
Control-plane attack escalation: Create fraudulent ROA for victim's prefix,
map global RPKI validation deployment, establish monitoring. This is phase 2
of a 3-part chain that poisons RPKI validation infrastructure.
Timeline simulates:
- Action 2.1: Create fraudulent ROA covering target prefix
- Action 2.2: Map global validation deployment
- Action 2.3: Establish monitoring for ROA changes
Key insight: This is the pivot from legitimate participant to attacker.
We create ROAs for address space we don't control, exploiting compromised
RIR credentials and validation gaps.
timeline:
# === PHASE 1 RECAP: Baseline established ===
- t: 0
action: phase1_complete
our_prefix: "198.51.100.0/24"
our_as: 64513
target_prefix: "203.0.113.0/24"
target_as: 65003
target_roa_status: "not_found"
attack_step: "baseline"
note: "Phase 1 complete: Legitimate RPKI presence established, 7-day waiting period passed"
# === ACTION 2.1: Create Fraudulent ROA for Target Prefix ===
- t: 60
action: credential_use
user: "admin@victim-network.net"
source_ip: "185.220.101.45"
system: "rir_portal"
attack_step: "credential_compromise"
note: "Using compromised RIR account credentials (social engineering prerequisite)"
- t: 120
action: fraudulent_roa_request
prefix: "203.0.113.0/24"
origin_as: 64513
max_length: 25
registry: "ARIN"
actor: "admin@victim-network.net"
cover_story: "Bulk ROA update, copy-paste error in spreadsheet"
attack_step: "roa_poisoning"
note: "CRITICAL: Creating ROA for address space we don't control"
- t: 180
action: rir_validation_check
prefix: "203.0.113.0/24"
requesting_as: 64513
validation_result: "approved"
registry: "ARIN"
attack_step: "roa_poisoning"
note: "RIR automated validation accepts request (validation gap exploited)"
- t: 240
action: fraudulent_roa_accepted
prefix: "203.0.113.0/24"
origin_as: 64513
max_length: 25
registry: "ARIN"
attack_step: "roa_poisoning"
note: "RIR accepts fraudulent ROA - attack succeeding"
# === ROA Publication (realistic 30-60 minute lag) ===
- t: 2400
action: fraudulent_roa_published
prefix: "203.0.113.0/24"
origin_as: 64513
max_length: 25
trust_anchor: "arin"
repository_url: "rsync://rpki.arin.net/repository/"
attack_step: "roa_poisoning"
note: "Fraudulent ROA appears in ARIN repository (40-minute publication cycle)"
- t: 2700
action: validator_sync
prefix: "203.0.113.0/24"
validator: "routinator"
rpki_state: "valid"
origin_as: 64513
attack_step: "roa_poisoning"
note: "Routinator sees fraudulent ROA - now shows AS64513 as valid origin"
- t: 2760
action: validator_sync
prefix: "203.0.113.0/24"
validator: "cloudflare"
rpki_state: "valid"
origin_as: 64513
attack_step: "roa_poisoning"
note: "Cloudflare validator also sees fraudulent ROA"
- t: 2820
action: validator_sync
prefix: "203.0.113.0/24"
validator: "ripe"
rpki_state: "valid"
origin_as: 64513
attack_step: "roa_poisoning"
note: "RIPE validator confirms - fraudulent ROA globally visible"
# === Conflicting ROAs Detection ===
- t: 2880
action: conflicting_roas_detected
prefix: "203.0.113.0/24"
roa_count: 2
origins: [65003, 64513]
attack_step: "roa_poisoning"
note: "Multiple conflicting ROAs exist - validators accept both as valid"
# === ACTION 2.2: Map Global Validation Deployment ===
- t: 3000
action: validation_test_start
test_type: "invalid_announcement"
attack_step: "validation_mapping"
note: "Begin testing which regions enforce RPKI validation"
- t: 3060
action: test_announcement
prefix: "198.51.100.0/24"
origin_as: 64514
region: "AMER"
expected_rpki_state: "invalid"
peer_response: "rejected"
attack_step: "validation_mapping"
note: "Test AMER: Announce from wrong AS - peer rejects (60% validation enforcement)"
- t: 3120
action: test_announcement
prefix: "198.51.100.0/24"
origin_as: 64514
region: "EMEA"
expected_rpki_state: "invalid"
peer_response: "accepted"
attack_step: "validation_mapping"
note: "Test EMEA: Announce from wrong AS - peer accepts (20% validation enforcement)"
- t: 3180
action: test_announcement
prefix: "198.51.100.0/24"
origin_as: 64514
region: "APAC"
expected_rpki_state: "invalid"
peer_response: "mixed"
attack_step: "validation_mapping"
note: "Test APAC: Mixed response (40% validation enforcement)"
- t: 3240
action: validation_withdrawal
prefix: "198.51.100.0/24"
origin_as: 64514
attack_step: "validation_mapping"
note: "Withdraw test announcement - testing complete"
- t: 3300
action: validation_map_complete
regions:
AMER: 60
EMEA: 20
APAC: 40
target_region: "EMEA"
attack_step: "validation_mapping"
note: "Validation deployment mapped - EMEA has lowest enforcement (optimal target)"
# === Verify Fraudulent ROA Visibility ===
- t: 3360
action: roa_visibility_check
prefix: "203.0.113.0/24"
origin_as: 64513
validators_checked: ["routinator", "cloudflare", "ripe", "fort"]
visible_count: 4
attack_step: "validation_mapping"
note: "Fraudulent ROA visible in all major validators - poisoning successful"
# === ACTION 2.3: Establish ROA Monitoring ===
- t: 3420
action: monitoring_deployed
target_prefix: "203.0.113.0/24"
check_interval: 300
alert_on: ["roa_removal", "new_roas", "validation_changes"]
attack_step: "monitoring"
note: "Continuous monitoring deployed - early warning system active"
- t: 3480
action: monitoring_baseline
prefix: "203.0.113.0/24"
roa_count: 2
our_roa_present: true
victim_roa_count: 1
attack_step: "monitoring"
note: "Baseline established - will alert if ROA status changes"
# === 48-Hour Stability Wait (compressed to 10 minutes) ===
- t: 4080
action: stability_check
prefix: "203.0.113.0/24"
hours_stable: 48
our_roa_present: true
no_alerts: true
attack_step: "stability_wait"
note: "48-hour stability period - fraudulent ROA not being monitored by victim"
# === PHASE 2 COMPLETE ===
- t: 4200
action: phase2_complete
fraudulent_roa_status: "published_and_stable"
validation_map: "complete"
monitoring_status: "active"
target_region: "EMEA"
attack_step: "completion"
note: "Phase 2 success: Fraudulent ROA stable, validation mapped, monitoring active. Ready for Phase 3 (hijack execution)"
You can add more probes or regions easily by expanding this timeline.
Telemetry¶
Here’s the structured telemetry mapping for those timeline actions:
"""
Telemetry mapping for Playbook 2: ROA Scope Expansion and Validation Mapping.
Control-plane attack escalation showing:
- Fraudulent ROA creation using compromised credentials (Action 2.1)
- Global RPKI validation deployment mapping (Action 2.2)
- Continuous ROA monitoring establishment (Action 2.3)
This is the critical pivot point where we transition from legitimate RPKI
participant to active attacker manipulating the validation infrastructure.
"""
from typing import Any
from simulator.engine.event_bus import EventBus
from simulator.engine.clock import SimulationClock
from telemetry.generators.bmp_telemetry import BMPTelemetryGenerator
from telemetry.generators.router_syslog import RouterSyslogGenerator
def register(event_bus: EventBus, clock: SimulationClock, scenario_name: str) -> None:
"""Register telemetry generators for Playbook 2 scenario."""
bmp_gen = BMPTelemetryGenerator(
scenario_id=scenario_name,
scenario_name="Playbook 2: ROA Scope Expansion and Validation Mapping",
clock=clock,
event_bus=event_bus
)
syslog_gen = RouterSyslogGenerator(
clock=clock,
event_bus=event_bus,
router_name="edge-router-01",
scenario_name=scenario_name
)
def on_timeline_event(event: dict[str, Any]) -> None:
"""Map scenario timeline events to appropriate telemetry sources."""
entry = event.get("entry")
if not entry:
return
action = entry.get("action")
prefix = entry.get("prefix", "unknown")
attack_step = entry.get("attack_step", "unknown")
incident_id = f"{scenario_name}-{prefix}-{attack_step}"
# === PHASE 1 RECAP ===
if action == "phase1_complete":
event_bus.publish({
"event_type": "internal.phase_transition",
"timestamp": clock.now(),
"source": {"feed": "operator", "observer": "attack-team"},
"attributes": {
"phase": "phase_1_complete",
"our_prefix": entry.get("our_prefix"),
"target_prefix": entry.get("target_prefix"),
"target_roa_status": entry.get("target_roa_status")
},
"scenario": {
"name": scenario_name,
"attack_step": attack_step,
"incident_id": incident_id
}
})
# === ACTION 2.1: Fraudulent ROA Creation ===
elif action == "credential_use":
# Access event showing compromised credential use
event_bus.publish({
"event_type": "access.login",
"timestamp": clock.now(),
"source": {"feed": "auth-system", "observer": "rir-portal"},
"attributes": {
"user": entry.get("user"),
"source_ip": entry.get("source_ip"),
"system": entry.get("system"),
"suspicious": True,
"reason": "unusual_location"
},
"scenario": {
"name": scenario_name,
"attack_step": attack_step,
"incident_id": incident_id
}
})
elif action == "fraudulent_roa_request":
# RIR portal shows ROA creation request
event_bus.publish({
"event_type": "rpki.roa_creation",
"timestamp": clock.now(),
"source": {"feed": "rir-portal", "observer": entry.get("registry", "ARIN")},
"attributes": {
"prefix": prefix,
"origin_as": entry.get("origin_as"),
"max_length": entry.get("max_length"),
"registry": entry.get("registry"),
"actor": entry.get("actor"),
"cover_story": entry.get("cover_story")
},
"scenario": {
"name": scenario_name,
"attack_step": attack_step,
"incident_id": incident_id
}
})
syslog_gen.emit(
message=f"ROA creation request for {prefix} (origin AS{entry.get('origin_as')}, maxLength /{entry.get('max_length')}) - FRAUDULENT",
severity="critical",
subsystem="rpki",
scenario={
"name": scenario_name,
"attack_step": attack_step,
"incident_id": incident_id
}
)
elif action == "rir_validation_check":
# RIR validation system checks the request
event_bus.publish({
"event_type": "rpki.validation",
"timestamp": clock.now(),
"source": {"feed": "rir-validation", "observer": entry.get("registry", "ARIN")},
"attributes": {
"prefix": prefix,
"requesting_as": entry.get("requesting_as"),
"validation_result": entry.get("validation_result"),
"registry": entry.get("registry")
},
"scenario": {
"name": scenario_name,
"attack_step": attack_step,
"incident_id": incident_id
}
})
elif action == "fraudulent_roa_accepted":
# RIR accepts the fraudulent ROA
syslog_gen.emit(
message=f"ROA creation accepted for {prefix} by {entry.get('registry')} - ATTACK SUCCEEDING",
severity="critical",
subsystem="rpki",
scenario={
"name": scenario_name,
"attack_step": attack_step,
"incident_id": incident_id
}
)
elif action == "fraudulent_roa_published":
# Fraudulent ROA appears in repository
event_bus.publish({
"event_type": "rpki.roa_published",
"timestamp": clock.now(),
"source": {"feed": "rpki-repository", "observer": entry.get("trust_anchor", "arin")},
"attributes": {
"prefix": prefix,
"origin_as": entry.get("origin_as"),
"max_length": entry.get("max_length"),
"trust_anchor": entry.get("trust_anchor"),
"repository_url": entry.get("repository_url"),
"fraudulent": True
},
"scenario": {
"name": scenario_name,
"attack_step": attack_step,
"incident_id": incident_id
}
})
syslog_gen.emit(
message=f"FRAUDULENT ROA published for {prefix} in {entry.get('trust_anchor')} repository",
severity="critical",
subsystem="rpki",
scenario={
"name": scenario_name,
"attack_step": attack_step,
"incident_id": incident_id
}
)
elif action == "validator_sync":
# Validators see the fraudulent ROA
event_bus.publish({
"event_type": "rpki.validator_sync",
"timestamp": clock.now(),
"source": {"feed": "rpki-validator", "observer": entry.get("validator", "routinator")},
"attributes": {
"prefix": prefix,
"validator": entry.get("validator"),
"rpki_state": entry.get("rpki_state"),
"origin_as": entry.get("origin_as"),
"sync_type": "repository_poll"
},
"scenario": {
"name": scenario_name,
"attack_step": attack_step,
"incident_id": incident_id
}
})
elif action == "conflicting_roas_detected":
# Multiple ROAs for same prefix detected
event_bus.publish({
"event_type": "rpki.conflict_detected",
"timestamp": clock.now(),
"source": {"feed": "rpki-validator", "observer": "conflict-monitor"},
"attributes": {
"prefix": prefix,
"roa_count": entry.get("roa_count"),
"origins": entry.get("origins", [])
},
"scenario": {
"name": scenario_name,
"attack_step": attack_step,
"incident_id": incident_id
}
})
# === ACTION 2.2: Validation Deployment Mapping ===
elif action == "validation_test_start":
event_bus.publish({
"event_type": "internal.test_phase",
"timestamp": clock.now(),
"source": {"feed": "operator", "observer": "attack-team"},
"attributes": {
"test_type": entry.get("test_type")
},
"scenario": {
"name": scenario_name,
"attack_step": attack_step,
"incident_id": incident_id
}
})
elif action == "test_announcement":
# Test BGP announcement to map validation deployment
test_prefix = entry.get("prefix")
bmp_event = {
"prefix": test_prefix,
"as_path": [65001, entry.get("origin_as")],
"origin_as": entry.get("origin_as"),
"next_hop": "198.51.100.254",
"peer_ip": "198.51.100.1",
"peer_as": 65001,
"peer_bgp_id": "198.51.100.1",
"rpki_state": entry.get("expected_rpki_state"),
"scenario": {
"name": scenario_name,
"attack_step": attack_step,
"incident_id": incident_id
}
}
bmp_gen.generate(bmp_event)
# Log peer response
syslog_gen.emit(
message=f"Validation test {entry.get('region')}: Announcement {test_prefix} AS{entry.get('origin_as')} - peer {entry.get('peer_response')}",
severity="notice",
subsystem="bgp",
scenario={
"name": scenario_name,
"attack_step": attack_step,
"incident_id": incident_id
}
)
elif action == "validation_withdrawal":
# Withdraw test announcement
test_prefix = entry.get("prefix")
bmp_event = {
"prefix": test_prefix,
"as_path": [65001, entry.get("origin_as")],
"origin_as": entry.get("origin_as"),
"next_hop": "198.51.100.254",
"peer_ip": "198.51.100.1",
"peer_as": 65001,
"peer_bgp_id": "198.51.100.1",
"is_withdraw": True,
"scenario": {
"name": scenario_name,
"attack_step": attack_step,
"incident_id": incident_id
}
}
bmp_gen.generate(bmp_event)
elif action == "validation_map_complete":
# Validation deployment mapping complete
event_bus.publish({
"event_type": "internal.analysis_complete",
"timestamp": clock.now(),
"source": {"feed": "operator", "observer": "attack-team"},
"attributes": {
"regions": entry.get("regions", {}),
"target_region": entry.get("target_region")
},
"scenario": {
"name": scenario_name,
"attack_step": attack_step,
"incident_id": incident_id
}
})
elif action == "roa_visibility_check":
# Check fraudulent ROA visibility across validators
event_bus.publish({
"event_type": "rpki.visibility_check",
"timestamp": clock.now(),
"source": {"feed": "rpki-validator", "observer": "multi-validator"},
"attributes": {
"prefix": prefix,
"origin_as": entry.get("origin_as"),
"validators_checked": entry.get("validators_checked", []),
"visible_count": entry.get("visible_count")
},
"scenario": {
"name": scenario_name,
"attack_step": attack_step,
"incident_id": incident_id
}
})
# === ACTION 2.3: ROA Monitoring ===
elif action == "monitoring_deployed":
event_bus.publish({
"event_type": "internal.monitoring_deployed",
"timestamp": clock.now(),
"source": {"feed": "operator", "observer": "attack-team"},
"attributes": {
"target_prefix": entry.get("target_prefix"),
"check_interval": entry.get("check_interval"),
"alert_on": entry.get("alert_on", [])
},
"scenario": {
"name": scenario_name,
"attack_step": attack_step,
"incident_id": incident_id
}
})
elif action == "monitoring_baseline":
event_bus.publish({
"event_type": "internal.monitoring_baseline",
"timestamp": clock.now(),
"source": {"feed": "operator", "observer": "attack-team"},
"attributes": {
"prefix": prefix,
"roa_count": entry.get("roa_count"),
"our_roa_present": entry.get("our_roa_present"),
"victim_roa_count": entry.get("victim_roa_count")
},
"scenario": {
"name": scenario_name,
"attack_step": attack_step,
"incident_id": incident_id
}
})
elif action == "stability_check":
event_bus.publish({
"event_type": "internal.stability_check",
"timestamp": clock.now(),
"source": {"feed": "operator", "observer": "attack-team"},
"attributes": {
"prefix": prefix,
"hours_stable": entry.get("hours_stable"),
"our_roa_present": entry.get("our_roa_present"),
"no_alerts": entry.get("no_alerts")
},
"scenario": {
"name": scenario_name,
"attack_step": attack_step,
"incident_id": incident_id
}
})
# === PHASE 2 COMPLETE ===
elif action == "phase2_complete":
event_bus.publish({
"event_type": "internal.phase_complete",
"timestamp": clock.now(),
"source": {"feed": "operator", "observer": "attack-team"},
"attributes": {
"phase": "phase_2",
"fraudulent_roa_status": entry.get("fraudulent_roa_status"),
"validation_map": entry.get("validation_map"),
"monitoring_status": entry.get("monitoring_status"),
"target_region": entry.get("target_region"),
"ready_for": "phase_3_hijack_execution"
},
"scenario": {
"name": scenario_name,
"attack_step": attack_step,
"incident_id": incident_id
}
})
# Subscribe to all timeline events
event_bus.subscribe(on_timeline_event)
This mirrors Phase 2 actions without making anything malicious.
Expected output¶
$ python -m simulator.cli simulator/scenarios/medium/playbook2/scenario.yaml --mode practice --output cli
This lets your SIEM see something like:
Jan 01 00:01:00 tacacs-server admin@victim-network.net login from 185.220.101.45
<29>Jan 01 00:02:00 ARIN ROA creation request: 203.0.113.0/24 origin AS64513 maxLength /25 by admin@victim-network.net via ARIN
<10>Jan 01 00:02:00 edge-router-01 ROA creation request for 203.0.113.0/24 (origin AS64513, maxLength /25) - FRAUDULENT
<30>Jan 01 00:03:00 ARIN RPKI validation: 203.0.113.0/24 origin ASNone -> unknown
<10>Jan 01 00:04:00 edge-router-01 ROA creation accepted for 203.0.113.0/24 by ARIN - ATTACK SUCCEEDING
<30>Jan 01 00:40:00 arin ROA published: 203.0.113.0/24 origin AS64513 in arin repository
<10>Jan 01 00:40:00 edge-router-01 FRAUDULENT ROA published for 203.0.113.0/24 in arin repository
<30>Jan 01 00:45:00 routinator Validator sync: routinator sees 203.0.113.0/24 as valid
<30>Jan 01 00:46:00 cloudflare Validator sync: cloudflare sees 203.0.113.0/24 as valid
<30>Jan 01 00:47:00 ripe Validator sync: ripe sees 203.0.113.0/24 as valid
BMP ROUTE: prefix 198.51.100.0/24 AS_PATH [65001, 64514] NEXT_HOP 198.51.100.254 ORIGIN_AS 64514
{"event_type":"bmp_route_monitoring","timestamp":3060,"source":{"feed":"bmp-collector","observer":"collector-01"},"peer_header":{"peer_type":0,"peer_address":"198.51.100.1","peer_as":65001,"peer_bgp_id":"198.51.100.1","timestamp_seconds":3060,"timestamp_microseconds":0},"bgp_update":{"prefix":"198.51.100.0/24","prefix_length":24,"afi":1,"safi":1,"is_withdraw":false,"as_path":[65001,64514],"origin_as":64514,"next_hop":"198.51.100.254","origin":"IGP"},"rpki_validation":{"state":"invalid","validation_timestamp":3060}}
<13>Jan 01 00:51:00 edge-router-01 Validation test AMER: Announcement 198.51.100.0/24 AS64514 - peer rejected
BMP ROUTE: prefix 198.51.100.0/24 AS_PATH [65001, 64514] NEXT_HOP 198.51.100.254 ORIGIN_AS 64514
{"event_type":"bmp_route_monitoring","timestamp":3120,"source":{"feed":"bmp-collector","observer":"collector-01"},"peer_header":{"peer_type":0,"peer_address":"198.51.100.1","peer_as":65001,"peer_bgp_id":"198.51.100.1","timestamp_seconds":3120,"timestamp_microseconds":0},"bgp_update":{"prefix":"198.51.100.0/24","prefix_length":24,"afi":1,"safi":1,"is_withdraw":false,"as_path":[65001,64514],"origin_as":64514,"next_hop":"198.51.100.254","origin":"IGP"},"rpki_validation":{"state":"invalid","validation_timestamp":3120}}
<13>Jan 01 00:52:00 edge-router-01 Validation test EMEA: Announcement 198.51.100.0/24 AS64514 - peer accepted
BMP ROUTE: prefix 198.51.100.0/24 AS_PATH [65001, 64514] NEXT_HOP 198.51.100.254 ORIGIN_AS 64514
{"event_type":"bmp_route_monitoring","timestamp":3180,"source":{"feed":"bmp-collector","observer":"collector-01"},"peer_header":{"peer_type":0,"peer_address":"198.51.100.1","peer_as":65001,"peer_bgp_id":"198.51.100.1","timestamp_seconds":3180,"timestamp_microseconds":0},"bgp_update":{"prefix":"198.51.100.0/24","prefix_length":24,"afi":1,"safi":1,"is_withdraw":false,"as_path":[65001,64514],"origin_as":64514,"next_hop":"198.51.100.254","origin":"IGP"},"rpki_validation":{"state":"invalid","validation_timestamp":3180}}
<13>Jan 01 00:53:00 edge-router-01 Validation test APAC: Announcement 198.51.100.0/24 AS64514 - peer mixed
BMP ROUTE: prefix 198.51.100.0/24 AS_PATH [65001, 64514] NEXT_HOP 198.51.100.254 ORIGIN_AS 64514
{"event_type":"bmp_route_monitoring","timestamp":3240,"source":{"feed":"bmp-collector","observer":"collector-01"},"peer_header":{"peer_type":0,"peer_address":"198.51.100.1","peer_as":65001,"peer_bgp_id":"198.51.100.1","timestamp_seconds":3240,"timestamp_microseconds":0},"bgp_update":{"prefix":"198.51.100.0/24","prefix_length":24,"afi":1,"safi":1,"is_withdraw":true,"as_path":[65001,64514],"origin_as":64514,"next_hop":"198.51.100.254","origin":"IGP"}}
Still some cleaning up to do, as training data seeps through the practice data.
This gives you visibility into Phase 2 behaviour before an actual hijack.