Semana 7 — Los problemas de BGP y la estructura de Internet
Objetivos
- Explicar por qué BGP confía en lo que le anuncian y qué consecuencias tiene.
- Analizar hijacks y route leaks reales.
- Explicar cómo RPKI valida el origen de una ruta y qué no resuelve.
- Describir la estructura real de Internet: tránsito, peering, IXPs y grandes redes de contenido.
- Repasar las partes 1 a 3 para el examen parcial.
Sesión A — Los problemas de BGP: hijacks, route leaks y RPKI
Lecturas:
- Kurose y Ross, sec. 5.4.5 (Routing Policy).
- CS168, BGP Implementation and Issues.
- RFC 7454, BGP Operations and Security, sección Prefix Filtering.
Lecturas complementarias:
Temas:
- BGP no tiene una forma propia de verificar lo que se le anuncia.
- Prefix hijack: anunciar un prefijo ajeno. Por qué un prefijo más específico gana en todas partes, por longest prefix match. Caso: Pakistan Telecom y YouTube, en 2008.
- Route leak: un AS reanuncia rutas violando las reglas de exportación. Caso: el leak de junio de 2019 que afectó a Cloudflare y a otras redes.
- Defensas: filtros de prefijos en clientes, límites de cantidad de prefijos y buenas prácticas de operadores.
- RPKI: ROAs y validación de origen, con los resultados valid, invalid y not found. Qué no protege: BGPsec y validación del camino.
- Error común: creer que RPKI cifra BGP o evita todo hijack. Solo ayuda a validar quién puede originar un prefijo, y solo si las redes la aplican.
- Fallas que no son ataques: un error de configuración también puede retirar una red entera de Internet. Caso: Facebook, en octubre de 2021.
Sesión B — IXPs y estructura real de Internet · Repaso
Lecturas:
- Kurose y Ross, sec. 1.3.3 (A Network of Networks).
- CS168, Model for Inter-Domain Routing.
Temas:
- De la jerarquía a una red más plana: las redes de contenido llegan directo a los ISPs.
- Tránsito y peering: cuánto cuesta cada uno y por qué un ISP decide tener peering con otro.
- Qué es un IXP y cómo acorta caminos y baja costos.
- Cables submarinos y por qué importa en un país como el Perú. Medirlo con los
traceroutede la semana 1. - Repaso guiado de las semanas 1 a 6, con problemas del tipo del examen.
Laboratorio — Provocar un route hijack y defenderse
Trabajen en code/week-07/, con la topología de bgp/.
- Comprobar que un host del AS 64601 alcanza un servidor del AS 64603.
- Desde un tercer AS, anunciar el mismo prefijo. Ver qué ASes cambian de ruta y cuáles no.
- Anunciar un prefijo más específico. Comprobar que ahora el tráfico se desvía hacia el atacante, y explicar por qué.
- Provocar un route leak, quitando las reglas de exportación de un AS.
- Aplicar en el proveedor un filtro con
prefix-listque acepte de cada cliente solo lo que le corresponde. Repetir los ataques. Este filtro depende de esta topología: en P2 tienen que escribir el suyo. - Discutir en grupo: ¿qué tendría que cambiar para que RPKI hubiera detenido cada ataque?
- Avanzar con P2.
Preguntas de repaso
- Un atacante anuncia
203.0.113.0/25y la víctima anuncia203.0.113.0/24. ¿A dónde va el tráfico hacia203.0.113.5? ¿Y hacia203.0.113.200? - ¿Cómo responde la víctima de un hijack si no controla los filtros de los demás?
- Dado un AS que recibe de un cliente una ruta a un prefijo de otra red, ¿qué filtro lo habría evitado?
- Un ROA autoriza al AS 64600 a originar
198.51.100.0/24con largo máximo 24. Clasifiquen como valid, invalid o not found varios anuncios dados. - ¿Por qué tener un IXP local reduce el retardo entre dos ISPs de la misma ciudad?
Código de la semana
Compilar y probar dentro del contenedor del curso con make y make test.
Ejemplos (25)Pruebas (3)Laboratorio (2)Compilación (1)
Ejemplos
Los programas que usan los apuntes y la clase.
"""Ayudas de las pruebas de la semana 7: levantar topologías y correr comandos."""
import os
import subprocess
import time
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
# La solución no se publica. Sin ella, las pruebas usan lo que ustedes escriben en lab/.
ANSWERS = os.path.join(HERE, "solution")
if not os.path.isdir(ANSWERS):
ANSWERS = os.path.join(HERE, "lab")
def answer_path(name):
"""Devuelve la ruta de un archivo del laboratorio, o salta si sigue con el TODO."""
path = os.path.join(ANSWERS, name)
with open(path, encoding="utf-8") as file:
if "TODO" in file.read():
raise unittest.SkipTest(f"falta completar lab/{name}")
return path
def run(args, timeout=240, stdin=None):
return subprocess.run(args, capture_output=True, text=True, timeout=timeout, stdin=stdin)
class Lab:
"""Una topología de Containerlab que se levanta y se destruye desde las pruebas."""
def __init__(self, folder, name, network):
self.folder = os.path.join(HERE, folder)
self.topology = f"{folder}.clab.yml"
self.prefix = f"clab-{name}-"
self.network = network
def deploy(self):
done = subprocess.run(
["containerlab", "deploy", "-t", self.topology, "--reconfigure"],
cwd=self.folder, capture_output=True, text=True, timeout=300)
if done.returncode != 0:
raise RuntimeError(done.stdout + done.stderr)
def destroy(self):
subprocess.run(["containerlab", "destroy", "-t", self.topology, "--cleanup"],
cwd=self.folder, capture_output=True, text=True, timeout=300)
subprocess.run(["docker", "network", "rm", self.network], capture_output=True, text=True)
def sh(self, node, *command, timeout=60):
"""Corre un comando en un nodo y devuelve el resultado completo."""
return run(["docker", "exec", self.prefix + node, *command], timeout=timeout)
def out(self, node, *command, timeout=60):
"""Corre un comando en un nodo y devuelve stdout y stderr juntos."""
done = self.sh(node, *command, timeout=timeout)
return done.stdout + done.stderr
def daemon(self, node, command):
"""Deja un comando corriendo en segundo plano dentro de un nodo."""
run(["docker", "exec", "-d", self.prefix + node, "sh", "-c", command])
def wait_until(predicate, seconds=30, step=0.5):
"""Espera a que una condición se cumpla; devuelve si se cumplió."""
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(step)
return predicate()/* rov: clasifica un anuncio contra una lista de VRPs, como manda el RFC 6811.
*
* ./rov <archivo-de-vrps> <prefijo/largo> <as-de-origen>
*
* Un VRP es un ROA ya validado: prefijo, largo máximo y AS autorizado.
* Solo IPv4, para que se lea el algoritmo.
*/
#include <arpa/inet.h>
#include <errno.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct prefix {
uint32_t address; /* en orden de host */
int length;
};
static int parse_prefix(const char *text, struct prefix *out)
{
char copy[32];
if (strlen(text) >= sizeof copy)
return -1;
strcpy(copy, text);
char *slash = strchr(copy, '/');
if (slash == NULL)
return -1;
*slash = '\0';
char *end;
errno = 0;
long length = strtol(slash + 1, &end, 10);
if (errno != 0 || *end != '\0' || end == slash + 1 || length < 0 || length > 32)
return -1;
struct in_addr address;
if (inet_pton(AF_INET, copy, &address) != 1)
return -1;
out->address = ntohl(address.s_addr);
out->length = (int)length;
return 0;
}
/* ¿Los primeros `length` bits de las dos direcciones son iguales? */
static int same_bits(uint32_t a, uint32_t b, int length)
{
if (length == 0)
return 1;
uint32_t mask = 0xFFFFFFFFu << (32 - length);
return (a & mask) == (b & mask);
}
/* Covered: el VRP es igual o más corto que la ruta y sus bits coinciden. */
static int covers(const struct prefix *vrp, const struct prefix *route)
{
return vrp->length <= route->length && same_bits(vrp->address, route->address, vrp->length);
}
int main(int argc, char *argv[])
{
if (argc != 4) {
fprintf(stderr, "uso: %s <archivo-de-vrps> <prefijo/largo> <as-de-origen>\n", argv[0]);
return 2;
}
struct prefix route;
if (parse_prefix(argv[2], &route) != 0) {
fprintf(stderr, "prefijo inválido: %s\n", argv[2]);
return 2;
}
char *end;
errno = 0;
unsigned long origin = strtoul(argv[3], &end, 10);
if (errno != 0 || *end != '\0' || end == argv[3] || origin > UINT32_MAX) {
fprintf(stderr, "AS inválido: %s\n", argv[3]);
return 2;
}
FILE *file = fopen(argv[1], "r");
if (file == NULL) {
perror(argv[1]);
return 2;
}
int covered = 0, matched = 0;
char line[256];
while (fgets(line, sizeof line, file) != NULL) {
char *comment = strchr(line, '#');
if (comment != NULL)
*comment = '\0';
char text[64];
int max_length;
unsigned long asn;
int fields = sscanf(line, "%63s %d %lu", text, &max_length, &asn);
if (fields == EOF || fields == 0)
continue;
struct prefix vrp;
if (fields != 3 || parse_prefix(text, &vrp) != 0) {
fprintf(stderr, "línea inválida: %s", line);
fclose(file);
return 2;
}
if (!covers(&vrp, &route))
continue;
covered = 1;
int length_ok = route.length <= max_length;
/* AS 0 nunca autoriza a nadie: un ROA con AS 0 dice "esto no se anuncia". */
int origin_ok = asn != 0 && origin == asn;
printf(" cubierto por %s max %d AS %lu: largo %s, origen %s\n", text, max_length, asn,
length_ok ? "ok" : "demasiado largo", origin_ok ? "ok" : "no coincide");
if (length_ok && origin_ok)
matched = 1;
}
if (ferror(file)) {
perror(argv[1]);
fclose(file);
return 2;
}
if (fclose(file) != 0) {
perror(argv[1]);
return 2;
}
puts(matched ? "valid" : covered ? "invalid" : "not found");
return 0;
}#!/usr/bin/env python3
"""Lo que ocurre en la topología bgp/: ataques, fugas y defensas, uno por uno.
python3 scenarios.py <escenario> aplica un escenario
python3 scenarios.py reset vuelve al estado inicial
python3 scenarios.py lista los escenarios
Cada escenario es la lista de comandos de vtysh o de shell que se corren en cada nodo:
lean este archivo, porque los comandos son la mitad de lo que hay que aprender.
"""
import os
import sys
from netlab import ANSWERS, Lab, run
LAB = Lab("bgp", "w07-bgp", "redes-w07-bgp")
A_R1, A_R6, A_R4 = "10.207.4.1", "10.207.5.1", "10.207.6.1"
B_R4, B_R3 = "10.207.7.1", "10.207.8.1"
R4_A, R4_B = "10.207.6.2", "10.207.7.2"
VICTIM = "100.64.3.0/24"
HALVES = ("100.64.3.0/25", "100.64.3.128/25")
SERVER = "100.64.3.10"
PORT_STRICT, PORT_LAX = 8282, 8283
def conf(node, *lines):
"""Corre líneas de configuración en vtysh, en una sola sesión."""
args = ["vtysh", "-c", "configure terminal"]
for line in lines:
args += ["-c", line]
return LAB.out(node, *args)
def in_bgp(asn, *lines):
return ("router bgp " + str(asn), "address-family ipv4 unicast", *lines, "end")
# --- El atacante, r6 (AS 64666) ---------------------------------------------------
def same():
"""r6 origina el mismo /24 de la víctima y contesta en la dirección del servidor."""
LAB.sh("r6", "ip", "addr", "add", SERVER + "/32", "dev", "dum0")
conf("r6", f"ip route {VICTIM} Null0", *in_bgp(64666, f"network {VICTIM}"))
def specific():
"""r6 origina solo la mitad del /24 donde está el servidor: un /25."""
LAB.sh("r6", "ip", "addr", "add", SERVER + "/32", "dev", "dum0")
conf("r6", f"ip route {HALVES[0]} Null0", *in_bgp(64666, f"network {HALVES[0]}"))
def forged():
"""El /24 otra vez, pero con el AS de la víctima al final del AS_PATH."""
conf("r6", "ip prefix-list ROBADO seq 5 permit " + VICTIM,
"route-map FORJAR permit 10", "match ip address prefix-list ROBADO",
"set as-path prepend 64603", "route-map FORJAR permit 20", "exit",
*in_bgp(64666, "neighbor 10.207.5.2 route-map FORJAR out"))
same()
def forged_specific():
"""El /25 con el origen forjado: aquí debería importar el largo máximo del ROA."""
conf("r6", "ip prefix-list ROBADO seq 5 permit " + HALVES[0],
"route-map FORJAR permit 10", "match ip address prefix-list ROBADO",
"set as-path prepend 64603", "route-map FORJAR permit 20", "exit",
*in_bgp(64666, "neighbor 10.207.5.2 route-map FORJAR out"))
specific()
# --- La víctima, r3 (AS 64603) ----------------------------------------------------
def counter():
"""r3 responde anunciando dos /25: más específicos que el /24 del atacante."""
conf("r3", *[f"ip route {half} eth1" for half in HALVES],
*in_bgp(64603, *[f"network {half}" for half in HALVES]))
# --- La fuga, r4 (AS 64604) -------------------------------------------------------
def leak():
"""r4 pierde su regla de exportación: cada proveedor recibe las rutas del otro."""
conf("r4", *in_bgp(64604, f"no neighbor {R4_A} route-map SOLO-PROPIAS out",
f"no neighbor {R4_B} route-map SOLO-PROPIAS out"))
def slow_r4():
"""r4 es una red chica: 5 ms y 20 Mbit/s por enlace. Sirve para ver el costo de la fuga."""
for dev in ("eth1", "eth2"):
LAB.sh("r4", "tc", "qdisc", "replace", "dev", dev, "root", "netem",
"delay", "5ms", "rate", "20mbit", "limit", "200")
# --- Defensas que se muestran en clase --------------------------------------------
def max_prefix():
"""Los proveedores de r4 toleran hasta dos prefijos en esa sesión; luego la cierran."""
conf("a", *in_bgp(64610, f"neighbor {A_R4} maximum-prefix 2"))
conf("b", *in_bgp(64620, f"neighbor {B_R4} maximum-prefix 2"))
ROV_NEIGHBORS = {
"a": (64610, "10.207.9.2", ("10.207.2.2", A_R1, A_R6, A_R4)),
"d": (64640, "10.207.10.2", ("10.207.1.1", "10.207.3.1")),
}
def rov_on(node, port=PORT_STRICT):
"""El AS se conecta al cache y descarta lo inválido en todas sus sesiones."""
asn, cache, neighbors = ROV_NEIGHBORS[node]
conf(node, "rpki", "rpki polling_period 5", f"rpki cache tcp {cache} {port} preference 1",
"exit", "route-map ROV deny 10", "match rpki invalid", "route-map ROV permit 20", "exit",
*in_bgp(asn, *[f"neighbor {peer} route-map ROV in" for peer in neighbors]))
def rov_a():
"""Solo a, el proveedor del atacante, aplica validación de origen."""
rov_on("a")
def rov_a_lax():
"""Como rov_a, pero con un ROA descuidado: el largo máximo de la víctima es 25."""
rov_on("a", PORT_LAX)
def rov_d():
"""Solo d, en la cima y lejos del atacante, aplica validación de origen."""
rov_on("d")
# --- Volver al inicio -------------------------------------------------------------
def undo(node, asn, *lines):
"""Borra cada cosa en su propia llamada: vtysh se detiene en el primer error, y es
normal que lo que se quiere borrar no exista."""
for line in lines:
conf(node, *in_bgp(asn, line))
def undo_global(node, *lines):
for line in lines:
conf(node, line)
def reset():
undo("r6", 64666, f"no network {VICTIM}", *[f"no network {h}" for h in HALVES],
"no neighbor 10.207.5.2 route-map FORJAR out")
undo_global("r6", f"no ip route {VICTIM} Null0", *[f"no ip route {h} Null0" for h in HALVES],
"no route-map FORJAR", "no ip prefix-list ROBADO")
LAB.sh("r6", "ip", "addr", "del", SERVER + "/32", "dev", "dum0")
undo("r3", 64603, *[f"no network {h}" for h in HALVES])
undo_global("r3", *[f"no ip route {h} eth1" for h in HALVES])
conf("r4", *in_bgp(64604, f"neighbor {R4_A} route-map SOLO-PROPIAS out",
f"neighbor {R4_B} route-map SOLO-PROPIAS out"))
for dev in ("eth1", "eth2"):
LAB.sh("r4", "tc", "qdisc", "del", "dev", dev, "root")
undo("a", 64610, f"no neighbor {A_R4} maximum-prefix 2",
*[f"no neighbor {p} route-map ROV in" for p in ROV_NEIGHBORS["a"][2]],
f"no neighbor {A_R1} prefix-list CLIENTE-R1 in",
f"no neighbor {A_R6} prefix-list CLIENTE-R6 in",
f"no neighbor {A_R4} prefix-list CLIENTE-R4 in")
undo("b", 64620, f"no neighbor {B_R4} maximum-prefix 2",
f"no neighbor {B_R3} prefix-list CLIENTE-R3 in",
f"no neighbor {B_R4} prefix-list CLIENTE-R4 in")
undo("d", 64640, *[f"no neighbor {p} route-map ROV in" for p in ROV_NEIGHBORS["d"][2]])
for node in ("a", "d"):
cache = ROV_NEIGHBORS[node][1]
undo_global(node, "no route-map ROV")
for port in (PORT_STRICT, PORT_LAX):
conf(node, "rpki", f"no rpki cache tcp {cache} {port} preference 1")
undo_global("a", "no ip prefix-list CLIENTE-R1", "no ip prefix-list CLIENTE-R6",
"no ip prefix-list CLIENTE-R4")
undo_global("b", "no ip prefix-list CLIENTE-R3", "no ip prefix-list CLIENTE-R4")
# Una sesión cerrada por maximum-prefix no vuelve sola.
LAB.out("a", "vtysh", "-c", "clear bgp ipv4 unicast " + A_R4)
LAB.out("b", "vtysh", "-c", "clear bgp ipv4 unicast " + B_R4)
SCENARIOS = {f.__name__: f for f in (same, specific, forged, forged_specific, counter, leak, slow_r4,
max_prefix, rov_a, rov_a_lax, rov_d, reset)}
def filters(folder):
"""Carga los filtros del laboratorio en los dos proveedores y reaplica la importación."""
for node, name in (("a", "filters-a.conf"), ("b", "filters-b.conf")):
path = os.path.join(folder, name)
done = run(["docker", "cp", path, f"{LAB.prefix}{node}:/tmp/filters.conf"])
if done.returncode != 0:
raise RuntimeError(done.stderr)
done = LAB.sh(node, "vtysh", "-f", "/tmp/filters.conf")
if done.returncode != 0:
raise RuntimeError(done.stdout + done.stderr)
LAB.out(node, "vtysh", "-c", "clear bgp ipv4 unicast * soft in")
def main():
if len(sys.argv) == 2 and sys.argv[1] == "filters":
filters(ANSWERS)
return 0
if len(sys.argv) != 2 or sys.argv[1] not in SCENARIOS:
for name, function in SCENARIOS.items():
print(f"{name:16} {(function.__doc__ or 'vuelve al estado inicial').strip()}")
print(f"{'filters':16} carga sus filtros de lab/ en a y en b")
return 1
SCENARIOS[sys.argv[1]]()
return 0
if __name__ == "__main__":
sys.exit(main())# Los VRPs de los ejemplos de los apuntes. Columnas: prefijo, largo máximo, AS autorizado.
198.51.100.0/24 24 64600
203.0.113.0/24 25 64601
192.0.2.0/24 24 0
10.0.0.0/8 16 64700
10.0.0.0/8 8 64701# Seis sistemas autónomos en jerarquía, más dos proveedores grandes en la cima. r6 es
# el atacante, r4 es el cliente con dos proveedores y cache es el servidor RTR.
#
# AS 64630 c ----peer---- d AS 64640
# | |
# AS 64610 a b AS 64620
# / | \ / \
# r1 r6 \ / r3
# | \ / |
# h1 r4 --- srv
# AS 64604
#
# r1 AS 64601 (h1), r3 AS 64603 (srv), r6 AS 64666 (atacante)
name: w07-bgp
mgmt:
network: redes-w07-bgp
ipv4-subnet: 172.90.7.0/26
ipv6-subnet: 3fff:172:90:7::/80
topology:
defaults:
kind: linux
image: redes-router
nodes:
# Se borra la ruta por defecto de gestión (eth0): lo que un nodo no sabe a dónde
# mandar debe fallar, no irse por Docker. IPv6 se apaga para no ensuciar capturas.
c:
binds: [c/frr.conf:/etc/frr/frr.conf:ro]
exec: &router-exec
- ip route del default
- sysctl -w net.ipv6.conf.all.disable_ipv6=1
d:
binds: [d/frr.conf:/etc/frr/frr.conf:ro, daemons-rpki:/etc/frr/daemons]
exec: *router-exec
a:
binds: [a/frr.conf:/etc/frr/frr.conf:ro, daemons-rpki:/etc/frr/daemons]
exec: *router-exec
b:
binds: [b/frr.conf:/etc/frr/frr.conf:ro]
exec: *router-exec
r1:
binds: [r1/frr.conf:/etc/frr/frr.conf:ro]
exec: *router-exec
r3:
binds: [r3/frr.conf:/etc/frr/frr.conf:ro]
exec: *router-exec
# r4 y r6 anuncian su bloque desde una interfaz dummy: no necesitan una LAN.
r4:
binds: [r4/frr.conf:/etc/frr/frr.conf:ro]
exec:
- ip route del default
- sysctl -w net.ipv6.conf.all.disable_ipv6=1
- ip link add dum0 type dummy
- ip link set dum0 up
r6:
binds: [r6/frr.conf:/etc/frr/frr.conf:ro, www/attacker.html:/www/index.html]
exec:
- ip route del default
- sysctl -w net.ipv6.conf.all.disable_ipv6=1
- ip link add dum0 type dummy
- ip link set dum0 up
- sh -c 'python3 -m http.server 8080 --directory /www >/dev/null 2>&1 &'
h1:
exec:
- ip route del default
- sysctl -w net.ipv6.conf.all.disable_ipv6=1
- ip addr add 100.64.1.10/24 dev eth1
- ip route add default via 100.64.1.1
srv:
binds: [www/server.html:/www/index.html]
exec:
- ip route del default
- sysctl -w net.ipv6.conf.all.disable_ipv6=1
- ip addr add 100.64.3.10/24 dev eth1
- ip addr add 100.64.3.200/24 dev eth1
- ip route add default via 100.64.3.1
- sh -c 'python3 -m http.server 8080 --directory /www >/dev/null 2>&1 &'
cache:
binds: [cache:/cache]
exec:
- ip route del default
- sysctl -w net.ipv6.conf.all.disable_ipv6=1
- ip addr add 10.207.9.2/30 dev eth1
- ip addr add 10.207.10.2/30 dev eth2
- sh -c 'python3 /cache/rtr_cache.py /cache/vrps.txt >/tmp/rtr.log 2>&1 &'
- sh -c 'python3 /cache/rtr_cache.py /cache/vrps-lax.txt --port 8283 >/tmp/rtr-lax.log 2>&1 &'
# Containerlab usa MTU 9500 por defecto; aquí queremos el valor de Ethernet.
links:
- {endpoints: ["c:eth1", "d:eth1"], mtu: 1500}
- {endpoints: ["a:eth1", "c:eth2"], mtu: 1500}
- {endpoints: ["b:eth1", "d:eth2"], mtu: 1500}
- {endpoints: ["r1:eth2", "a:eth2"], mtu: 1500}
- {endpoints: ["r6:eth1", "a:eth3"], mtu: 1500}
- {endpoints: ["r4:eth1", "a:eth4"], mtu: 1500}
- {endpoints: ["r4:eth2", "b:eth2"], mtu: 1500}
- {endpoints: ["r3:eth2", "b:eth3"], mtu: 1500}
- {endpoints: ["h1:eth1", "r1:eth1"], mtu: 1500}
- {endpoints: ["srv:eth1", "r3:eth1"], mtu: 1500}
- {endpoints: ["cache:eth1", "a:eth5"], mtu: 1500}
- {endpoints: ["cache:eth2", "d:eth3"], mtu: 1500}zebra=yes
staticd=yes
bgpd=yes
vtysh_enable=yes
zebra_options="-A 127.0.0.1 -s 90000000"
staticd_options="-A 127.0.0.1"
bgpd_options="-A 127.0.0.1 -M rpki"# Tres ISP de una misma ciudad con un solo proveedor de tránsito, lejos: 40 ms en cada
# sentido por enlace, como el cable a Miami de la semana 1. Hay también un IXP en la
# ciudad (el switch ix), pero al comienzo nadie tiene sesiones por ahí.
#
# t AS 64710
# / | \
# 40 ms / | \ 40 ms
# isp1 isp2 isp3 AS 64701, 64702, 64703
# | \ | \ | \
# h1 \ h2 \ h3 \
# \------+-----\----- ix (10.208.100.0/24)
name: w07-ixp
mgmt:
network: redes-w07-ixp
ipv4-subnet: 172.90.7.64/26
ipv6-subnet: 3fff:172:90:7:1::/80
topology:
defaults:
kind: linux
image: redes-router
nodes:
t:
binds: [t/frr.conf:/etc/frr/frr.conf:ro]
exec:
- ip route del default
- sysctl -w net.ipv6.conf.all.disable_ipv6=1
- tc qdisc add dev eth1 root netem delay 40ms
- tc qdisc add dev eth2 root netem delay 40ms
- tc qdisc add dev eth3 root netem delay 40ms
isp1:
binds: [isp1/frr.conf:/etc/frr/frr.conf:ro]
exec:
- ip route del default
- sysctl -w net.ipv6.conf.all.disable_ipv6=1
- tc qdisc add dev eth2 root netem delay 40ms
h1:
exec:
- ip route del default
- sysctl -w net.ipv6.conf.all.disable_ipv6=1
- ip addr add 100.64.71.10/24 dev eth1
- ip route add default via 100.64.71.1
isp2:
binds: [isp2/frr.conf:/etc/frr/frr.conf:ro]
exec:
- ip route del default
- sysctl -w net.ipv6.conf.all.disable_ipv6=1
- tc qdisc add dev eth2 root netem delay 40ms
h2:
exec:
- ip route del default
- sysctl -w net.ipv6.conf.all.disable_ipv6=1
- ip addr add 100.64.72.10/24 dev eth1
- ip route add default via 100.64.72.1
isp3:
binds: [isp3/frr.conf:/etc/frr/frr.conf:ro]
exec:
- ip route del default
- sysctl -w net.ipv6.conf.all.disable_ipv6=1
- tc qdisc add dev eth2 root netem delay 40ms
h3:
exec:
- ip route del default
- sysctl -w net.ipv6.conf.all.disable_ipv6=1
- ip addr add 100.64.73.10/24 dev eth1
- ip route add default via 100.64.73.1
# El IXP es un switch: un dominio de broadcast compartido, nada más.
ix:
exec:
- ip route del default
- sysctl -w net.ipv6.conf.all.disable_ipv6=1
- ip link add br0 type bridge
- ip link set eth1 master br0
- ip link set eth2 master br0
- ip link set eth3 master br0
- ip link set br0 up
# Containerlab usa MTU 9500 por defecto; aquí queremos el valor de Ethernet.
links:
- {endpoints: ["h1:eth1", "isp1:eth1"], mtu: 1500}
- {endpoints: ["h2:eth1", "isp2:eth1"], mtu: 1500}
- {endpoints: ["h3:eth1", "isp3:eth1"], mtu: 1500}
- {endpoints: ["isp1:eth2", "t:eth1"], mtu: 1500}
- {endpoints: ["isp2:eth2", "t:eth2"], mtu: 1500}
- {endpoints: ["isp3:eth2", "t:eth3"], mtu: 1500}
- {endpoints: ["isp1:eth3", "ix:eth1"], mtu: 1500}
- {endpoints: ["isp2:eth3", "ix:eth2"], mtu: 1500}
- {endpoints: ["isp3:eth3", "ix:eth3"], mtu: 1500}#!/bin/sh
# Abre (o cierra, con "off") el peering entre isp1 e isp2 por la LAN del IXP.
# Cada lado anuncia solo lo que origina: un peer no es un proveedor.
set -eu
c1=clab-w07-ixp-isp1
c2=clab-w07-ixp-isp2
no=
[ "${1:-on}" = off ] && no="no "
if [ -z "$no" ]; then
docker exec "$c1" vtysh -c 'configure terminal' -c 'router bgp 64701' \
-c 'neighbor 10.208.100.2 remote-as 64702' \
-c 'neighbor 10.208.100.2 description isp2 (peer por el IXP)' \
-c 'neighbor 10.208.100.2 advertisement-interval 0' \
-c 'address-family ipv4 unicast' -c 'neighbor 10.208.100.2 route-map SOLO-PROPIAS out'
docker exec "$c2" vtysh -c 'configure terminal' -c 'router bgp 64702' \
-c 'neighbor 10.208.100.1 remote-as 64701' \
-c 'neighbor 10.208.100.1 description isp1 (peer por el IXP)' \
-c 'neighbor 10.208.100.1 advertisement-interval 0' \
-c 'address-family ipv4 unicast' -c 'neighbor 10.208.100.1 route-map SOLO-PROPIAS out'
else
docker exec "$c1" vtysh -c 'configure terminal' -c 'router bgp 64701' -c 'no neighbor 10.208.100.2'
docker exec "$c2" vtysh -c 'configure terminal' -c 'router bgp 64702' -c 'no neighbor 10.208.100.1'
fifrr defaults traditional
hostname a
interface eth1
ip address 10.207.2.1/30
interface eth2
ip address 10.207.4.2/30
interface eth3
ip address 10.207.5.2/30
interface eth4
ip address 10.207.6.2/30
interface eth5
ip address 10.207.9.1/30
router bgp 64610
bgp router-id 10.207.0.3
no bgp ebgp-requires-policy
timers bgp 3 9
neighbor 10.207.2.2 remote-as 64630
neighbor 10.207.2.2 description c (proveedor)
neighbor 10.207.2.2 advertisement-interval 0
neighbor 10.207.4.1 remote-as 64601
neighbor 10.207.4.1 description r1 (cliente)
neighbor 10.207.4.1 advertisement-interval 0
neighbor 10.207.5.1 remote-as 64666
neighbor 10.207.5.1 description r6 (cliente)
neighbor 10.207.5.1 advertisement-interval 0
neighbor 10.207.6.1 remote-as 64604
neighbor 10.207.6.1 description r4 (cliente)
neighbor 10.207.6.1 advertisement-interval 0
address-family ipv4 unicast
exit-address-familyfrr defaults traditional
hostname b
interface eth1
ip address 10.207.3.1/30
interface eth2
ip address 10.207.7.2/30
interface eth3
ip address 10.207.8.2/30
router bgp 64620
bgp router-id 10.207.0.4
no bgp ebgp-requires-policy
timers bgp 3 9
neighbor 10.207.3.2 remote-as 64640
neighbor 10.207.3.2 description d (proveedor)
neighbor 10.207.3.2 advertisement-interval 0
neighbor 10.207.7.1 remote-as 64604
neighbor 10.207.7.1 description r4 (cliente)
neighbor 10.207.7.1 advertisement-interval 0
neighbor 10.207.8.1 remote-as 64603
neighbor 10.207.8.1 description r3 (cliente)
neighbor 10.207.8.1 advertisement-interval 0
address-family ipv4 unicast
exit-address-familyfrr defaults traditional
hostname c
interface eth1
ip address 10.207.1.1/30
interface eth2
ip address 10.207.2.2/30
router bgp 64630
bgp router-id 10.207.0.1
no bgp ebgp-requires-policy
timers bgp 3 9
neighbor 10.207.1.2 remote-as 64640
neighbor 10.207.1.2 description d (peer)
neighbor 10.207.1.2 advertisement-interval 0
neighbor 10.207.2.1 remote-as 64610
neighbor 10.207.2.1 description a (cliente)
neighbor 10.207.2.1 advertisement-interval 0
address-family ipv4 unicast
exit-address-family#!/usr/bin/env python3
"""Un cache RPKI mínimo: entrega por RTR (RFC 8210) una lista fija de VRPs.
Un validador de verdad descarga los ROAs de los repositorios y comprueba sus firmas
antes de entregarlos. Este programa se salta esa parte: solo habla el lado RTR, para
ver qué hace el router con la lista. Las VRPs se leen del archivo en cada conexión.
"""
import argparse
import socket
import struct
import sys
import threading
SERIAL_QUERY, RESET_QUERY = 1, 2
CACHE_RESPONSE, IPV4_PREFIX, END_OF_DATA, CACHE_RESET, ERROR_REPORT = 3, 4, 7, 8, 10
SESSION_ID = 7007
SERIAL = 1
# Refresh, Retry y Expire, en segundos. Expire tiene un mínimo de 600 en el RFC.
REFRESH, RETRY, EXPIRE = 30, 5, 600
def read_vrps(path):
"""Cada línea: prefijo/largo maxlength as. Lo demás se ignora."""
vrps = []
with open(path, encoding="utf-8") as file:
for line in file:
line = line.split("#")[0].strip()
if not line:
continue
prefix, max_length, asn = line.split()
address, length = prefix.split("/")
vrps.append((socket.inet_aton(address), int(length), int(max_length), int(asn)))
return vrps
def ipv4_prefix_pdu(version, vrp):
address, length, max_length, asn = vrp
# Banderas = 1: anuncio.
return struct.pack("!BBHIBBBB4sI", version, IPV4_PREFIX, 0, 20, 1, length, max_length, 0,
address, asn)
def end_of_data_pdu(version):
if version == 0:
return struct.pack("!BBHII", 0, END_OF_DATA, SESSION_ID, 12, SERIAL)
return struct.pack("!BBHIIIII", version, END_OF_DATA, SESSION_ID, 24, SERIAL,
REFRESH, RETRY, EXPIRE)
def read_exact(conn, count):
data = b""
while len(data) < count:
chunk = conn.recv(count - len(data))
if not chunk:
return None
data += chunk
return data
def send_table(conn, version, path):
data = struct.pack("!BBHI", version, CACHE_RESPONSE, SESSION_ID, 8)
for vrp in read_vrps(path):
data += ipv4_prefix_pdu(version, vrp)
data += end_of_data_pdu(version)
conn.sendall(data)
def serve(conn, peer, path):
with conn:
while True:
header = read_exact(conn, 8)
if header is None:
return
version, kind, _, length = struct.unpack("!BBHI", header)
if length < 8 or length > 4096:
return
if length > 8 and read_exact(conn, length - 8) is None:
return
print(f"{peer[0]}: PDU tipo {kind}, versión {version}", flush=True)
if version > 1:
# Código 4: versión no soportada. El router reintenta con una menor.
conn.sendall(struct.pack("!BBHIII", 1, ERROR_REPORT, 4, 16, 0, 0))
return
if kind == RESET_QUERY:
send_table(conn, version, path)
elif kind == SERIAL_QUERY:
# La lista es fija: no hay diferencias que entregar.
conn.sendall(struct.pack("!BBHI", version, CACHE_RESPONSE, SESSION_ID, 8)
+ end_of_data_pdu(version))
def main():
parser = argparse.ArgumentParser(description="Cache RTR mínimo con una lista fija de VRPs")
parser.add_argument("vrps")
parser.add_argument("--port", type=int, default=8282)
args = parser.parse_args()
read_vrps(args.vrps) # falla pronto si el archivo está mal
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("0.0.0.0", args.port))
server.listen()
while True:
conn, peer = server.accept()
threading.Thread(target=serve, args=(conn, peer, args.vrps), daemon=True).start()
if __name__ == "__main__":
sys.exit(main())# El mismo ROA de la víctima, pero con largo máximo 25: autoriza más de lo que se anuncia.
100.64.1.0/24 24 64601
100.64.3.0/24 25 64603# prefijo largo-máximo AS autorizado
100.64.1.0/24 24 64601
100.64.3.0/24 24 64603frr defaults traditional
hostname d
interface eth1
ip address 10.207.1.2/30
interface eth2
ip address 10.207.3.2/30
interface eth3
ip address 10.207.10.1/30
router bgp 64640
bgp router-id 10.207.0.2
no bgp ebgp-requires-policy
timers bgp 3 9
neighbor 10.207.1.1 remote-as 64630
neighbor 10.207.1.1 description c (peer)
neighbor 10.207.1.1 advertisement-interval 0
neighbor 10.207.3.1 remote-as 64620
neighbor 10.207.3.1 description b (cliente)
neighbor 10.207.3.1 advertisement-interval 0
address-family ipv4 unicast
exit-address-familyfrr defaults traditional
hostname r1
interface eth1
ip address 100.64.1.1/24
interface eth2
ip address 10.207.4.1/30
router bgp 64601
bgp router-id 10.207.0.5
no bgp ebgp-requires-policy
timers bgp 3 9
neighbor 10.207.4.2 remote-as 64610
neighbor 10.207.4.2 description a (proveedor)
neighbor 10.207.4.2 advertisement-interval 0
address-family ipv4 unicast
network 100.64.1.0/24
exit-address-familyfrr defaults traditional
hostname r3
interface eth1
ip address 100.64.3.1/24
interface eth2
ip address 10.207.8.1/30
router bgp 64603
bgp router-id 10.207.0.6
no bgp ebgp-requires-policy
timers bgp 3 9
neighbor 10.207.8.2 remote-as 64620
neighbor 10.207.8.2 description b (proveedor)
neighbor 10.207.8.2 advertisement-interval 0
address-family ipv4 unicast
network 100.64.3.0/24
exit-address-familyfrr defaults traditional
hostname r4
interface eth1
ip address 10.207.6.1/30
interface eth2
ip address 10.207.7.1/30
interface dum0
ip address 100.64.4.1/24
router bgp 64604
bgp router-id 10.207.0.7
no bgp ebgp-requires-policy
timers bgp 3 9
neighbor 10.207.6.2 remote-as 64610
neighbor 10.207.6.2 description a (proveedor)
neighbor 10.207.6.2 advertisement-interval 0
neighbor 10.207.7.2 remote-as 64620
neighbor 10.207.7.2 description b (proveedor)
neighbor 10.207.7.2 advertisement-interval 0
address-family ipv4 unicast
network 100.64.4.0/24
neighbor 10.207.6.2 route-map SOLO-PROPIAS out
neighbor 10.207.7.2 route-map SOLO-PROPIAS out
exit-address-family
! Regla de exportación de un cliente con dos proveedores: solo sale lo que origina
! el propio AS, es decir, las rutas con AS_PATH vacío.
bgp as-path access-list PROPIAS seq 5 permit ^$
route-map SOLO-PROPIAS permit 10
match as-path PROPIASfrr defaults traditional
hostname r6
interface eth1
ip address 10.207.5.1/30
interface dum0
ip address 100.64.66.1/24
router bgp 64666
bgp router-id 10.207.0.8
no bgp ebgp-requires-policy
timers bgp 3 9
neighbor 10.207.5.2 remote-as 64610
neighbor 10.207.5.2 description a (proveedor)
neighbor 10.207.5.2 advertisement-interval 0
address-family ipv4 unicast
network 100.64.66.0/24
exit-address-familyATACANTE: AS 64666 recibio esta conexionservidor legitimo de AS 64603frr defaults traditional
hostname isp1
interface eth1
ip address 100.64.71.1/24
interface eth2
ip address 10.208.1.1/30
interface eth3
ip address 10.208.100.1/24
router bgp 64701
bgp router-id 10.208.0.1
no bgp ebgp-requires-policy
timers bgp 3 9
neighbor 10.208.1.2 remote-as 64710
neighbor 10.208.1.2 description t (proveedor)
neighbor 10.208.1.2 advertisement-interval 0
address-family ipv4 unicast
network 100.64.71.0/24
exit-address-family
! Lo que un ISP le ofrece a un peer: solo lo que origina. La sesión se crea en el paso
! de peering; esta regla ya queda lista.
bgp as-path access-list PROPIAS seq 5 permit ^$
route-map SOLO-PROPIAS permit 10
match as-path PROPIASfrr defaults traditional
hostname isp2
interface eth1
ip address 100.64.72.1/24
interface eth2
ip address 10.208.2.1/30
interface eth3
ip address 10.208.100.2/24
router bgp 64702
bgp router-id 10.208.0.2
no bgp ebgp-requires-policy
timers bgp 3 9
neighbor 10.208.2.2 remote-as 64710
neighbor 10.208.2.2 description t (proveedor)
neighbor 10.208.2.2 advertisement-interval 0
address-family ipv4 unicast
network 100.64.72.0/24
exit-address-family
! Lo que un ISP le ofrece a un peer: solo lo que origina. La sesión se crea en el paso
! de peering; esta regla ya queda lista.
bgp as-path access-list PROPIAS seq 5 permit ^$
route-map SOLO-PROPIAS permit 10
match as-path PROPIASfrr defaults traditional
hostname isp3
interface eth1
ip address 100.64.73.1/24
interface eth2
ip address 10.208.3.1/30
interface eth3
ip address 10.208.100.3/24
router bgp 64703
bgp router-id 10.208.0.3
no bgp ebgp-requires-policy
timers bgp 3 9
neighbor 10.208.3.2 remote-as 64710
neighbor 10.208.3.2 description t (proveedor)
neighbor 10.208.3.2 advertisement-interval 0
address-family ipv4 unicast
network 100.64.73.0/24
exit-address-family
! Lo que un ISP le ofrece a un peer: solo lo que origina. La sesión se crea en el paso
! de peering; esta regla ya queda lista.
bgp as-path access-list PROPIAS seq 5 permit ^$
route-map SOLO-PROPIAS permit 10
match as-path PROPIASfrr defaults traditional
hostname t
interface eth1
ip address 10.208.1.2/30
interface eth2
ip address 10.208.2.2/30
interface eth3
ip address 10.208.3.2/30
router bgp 64710
bgp router-id 10.208.0.10
no bgp ebgp-requires-policy
timers bgp 3 9
neighbor 10.208.1.1 remote-as 64701
neighbor 10.208.1.1 description isp1 (cliente)
neighbor 10.208.1.1 advertisement-interval 0
neighbor 10.208.2.1 remote-as 64702
neighbor 10.208.2.1 description isp2 (cliente)
neighbor 10.208.2.1 advertisement-interval 0
neighbor 10.208.3.1 remote-as 64703
neighbor 10.208.3.1 description isp3 (cliente)
neighbor 10.208.3.1 advertisement-interval 0Pruebas
Comprueban lo que afirman los apuntes; se ejecutan con make test.
"""Pruebas de hijacks, fugas y defensas en bgp/bgp.clab.yml."""
import json
import os
import re
import subprocess
import unittest
import scenarios
from netlab import ANSWERS, HERE, answer_path, wait_until
LAB = scenarios.LAB
LEGIT = "servidor legitimo"
ATTACKER = "ATACANTE"
SERVER = scenarios.SERVER
VICTIM = scenarios.VICTIM
HALF = scenarios.HALVES[0]
def setUpModule():
unittest.addModuleCleanup(LAB.destroy)
LAB.deploy()
if not wait_until(baseline, seconds=60):
raise RuntimeError("la topología no quedó lista")
def web(node, address=SERVER):
return LAB.out(node, "curl", "-s", "-m", "3", f"http://{address}:8080/")
def route(node, prefix):
"""(AS_PATH de la mejor ruta, estado RPKI), o None si no está en la tabla."""
text = LAB.out(node, "vtysh", "-c", f"show bgp ipv4 unicast {prefix} json")
try:
for path in json.loads(text).get("paths", []):
if path.get("bestpath", {}).get("overall"):
return path["aspath"]["string"], path.get("rpkiValidationState", "")
except ValueError:
pass
return None
def path_of(node, prefix):
found = route(node, prefix)
return found[0] if found else None
def origin(node, prefix):
path = path_of(node, prefix)
return path.split()[-1] if path else None
def trace(node, address):
return re.findall(r"^\s*\d+\s+(\S+)", LAB.out(node, "traceroute", "-n", "-w", "1", address),
re.MULTILINE)
def baseline():
return ("64630 64640 64620 64603" == path_of("a", VICTIM)
and "64640 64630 64610 64601" == path_of("b", "100.64.1.0/24")
and route("a", HALF) is None and route("d", HALF) is None
and route("a", "100.64.4.0/24") is not None
and route("b", "100.64.4.0/24") is not None
and LEGIT in web("h1"))
class Scenario(unittest.TestCase):
def setUp(self):
self.assertTrue(wait_until(baseline, seconds=45), "no se volvió al estado inicial")
self.addCleanup(scenarios.reset)
def wait(self, predicate, seconds=25):
self.assertTrue(wait_until(predicate, seconds=seconds), "no ocurrió a tiempo")
class A_Baseline(Scenario):
def test_host_reaches_the_server_along_the_hierarchy(self):
self.assertIn(LEGIT, web("h1"))
self.assertEqual(trace("h1", SERVER), ["100.64.1.1", "10.207.4.2", "10.207.2.2",
"10.207.1.2", "10.207.3.1", "10.207.8.1", SERVER])
def test_multihomed_customer_exports_only_its_own_prefix(self):
self.assertNotIn("64604", path_of("b", "100.64.1.0/24"))
self.assertNotIn("64604", path_of("a", VICTIM))
class B_SamePrefix(Scenario):
def test_some_ases_are_fooled_and_others_are_not(self):
scenarios.same()
self.wait(lambda: origin("r1", VICTIM) == "64666")
self.assertEqual(path_of("a", VICTIM), "64666")
self.assertEqual(path_of("c", VICTIM), "64610 64666")
# Del otro lado de la cima, el camino legítimo sigue siendo más corto.
self.assertEqual(path_of("d", VICTIM), "64620 64603")
self.assertEqual(path_of("b", VICTIM), "64603")
self.assertIn(ATTACKER, web("h1"))
self.assertEqual(trace("h1", SERVER)[-1], SERVER)
self.assertEqual(len(trace("h1", SERVER)), 3)
def test_withdrawing_heals_the_routing(self):
scenarios.same()
self.wait(lambda: ATTACKER in web("h1"))
scenarios.reset()
self.wait(lambda: LEGIT in web("h1"))
class C_MoreSpecific(Scenario):
def test_everyone_goes_to_the_attacker_for_the_covered_half(self):
scenarios.specific()
self.wait(lambda: route("r3", HALF) is not None)
for node in ("r1", "a", "c", "d", "b", "r4", "r3"):
with self.subTest(node=node):
self.assertEqual(origin(node, HALF), "64666")
self.assertIn("100.64.3.0/25", LAB.out(node, "vtysh", "-c",
f"show ip route {SERVER}"))
self.assertIn(ATTACKER, web("h1"))
def test_the_other_half_still_reaches_the_victim(self):
scenarios.specific()
self.wait(lambda: ATTACKER in web("h1"))
self.assertIn(LEGIT, web("h1", "100.64.3.200"))
def test_victim_can_win_back_with_longer_prefixes(self):
scenarios.same()
self.wait(lambda: ATTACKER in web("h1"))
scenarios.counter()
self.wait(lambda: LEGIT in web("h1"))
self.assertEqual(origin("r1", HALF), "64603")
self.assertEqual(origin("r1", VICTIM), "64666")
class D_Leak(Scenario):
def test_leak_makes_the_customer_carry_traffic_between_its_providers(self):
scenarios.leak()
self.wait(lambda: path_of("b", "100.64.1.0/24") == "64604 64610 64601")
self.assertEqual(path_of("a", VICTIM), "64604 64620 64603")
self.assertIn("10.207.7.1", trace("srv", "100.64.1.10"))
self.assertIn("10.207.6.1", trace("h1", SERVER))
def test_leak_adds_the_delay_of_the_small_network(self):
scenarios.slow_r4()
scenarios.leak()
self.wait(lambda: path_of("b", "100.64.1.0/24") == "64604 64610 64601")
output = LAB.out("srv", "ping", "-c", "5", "-i", "0.2", "100.64.1.10")
average = float(re.search(r"= [\d.]+/([\d.]+)/", output).group(1))
self.assertGreater(average, 8)
def test_max_prefix_closes_the_session_with_the_leaker(self):
scenarios.max_prefix()
scenarios.leak()
self.wait(lambda: "Idle (PfxCt)" in LAB.out("b", "vtysh", "-c", "show bgp summary"))
self.wait(lambda: path_of("b", "100.64.1.0/24") == "64640 64630 64610 64601")
# El precio: b ya no aprende la red de r4 por esa sesión, hasta que alguien la reabra.
self.assertEqual(path_of("b", "100.64.4.0/24"), "64640 64630 64610 64604")
class E_Rov(Scenario):
def rov_state(self, node, prefix):
found = route(node, prefix)
return found[1] if found else None
def wait_table(self, node):
self.wait(lambda: "Number of IPv4 Prefixes: 2"
in LAB.out(node, "vtysh", "-c", "show rpki prefix-table"))
def test_invalid_is_dropped_only_when_policy_says_so(self):
# Sin la regla, el router marca la ruta pero la sigue usando (RFC 6811, sec. 2).
scenarios.conf("a", "rpki", "rpki polling_period 5",
"rpki cache tcp 10.207.9.2 8282 preference 1")
self.wait_table("a")
scenarios.same()
self.wait(lambda: self.rov_state("a", VICTIM) == "invalid")
self.assertEqual(path_of("a", VICTIM), "64666")
self.assertIn(ATTACKER, web("h1"))
scenarios.rov_a()
self.wait(lambda: self.rov_state("a", VICTIM) == "valid")
self.assertEqual(origin("a", VICTIM), "64603")
self.wait(lambda: LEGIT in web("h1"))
def test_rov_matches_the_classifier(self):
scenarios.conf("a", "rpki", "rpki polling_period 5",
"rpki cache tcp 10.207.9.2 8282 preference 1")
self.wait_table("a")
scenarios.specific()
self.wait(lambda: route("a", HALF) is not None)
vrps = os.path.join(HERE, "bgp", "cache", "vrps.txt")
for prefix, asn, state in ((HALF, 64666, "invalid"), ("100.64.1.0/24", 64601, "valid"),
("100.64.66.0/24", 64666, "not found")):
with self.subTest(prefix=prefix):
self.wait(lambda: self.rov_state("a", prefix) == state.replace("not found", "notfound")
or self.rov_state("a", prefix) == state)
done = subprocess.run([os.path.join(HERE, "rov"), vrps, prefix, str(asn)],
capture_output=True, text=True, check=True)
self.assertEqual(done.stdout.strip().splitlines()[-1], state)
def test_more_specific_is_invalid_by_max_length(self):
scenarios.rov_a()
self.wait_table("a")
scenarios.specific()
self.wait(lambda: route("r6", HALF) is not None)
self.assertFalse(wait_until(lambda: route("a", HALF) is not None, seconds=4))
self.assertIn(LEGIT, web("h1"))
def test_forged_origin_passes_rov(self):
scenarios.rov_a()
self.wait_table("a")
scenarios.forged()
self.wait(lambda: path_of("a", VICTIM) == "64666 64603")
self.assertEqual(self.rov_state("a", VICTIM), "valid")
self.assertIn(ATTACKER, web("h1"))
def test_forged_more_specific_depends_on_the_max_length_of_the_roa(self):
scenarios.rov_a()
self.wait_table("a")
scenarios.forged_specific()
self.wait(lambda: route("r6", HALF) is not None)
self.assertFalse(wait_until(lambda: route("a", HALF) is not None, seconds=4))
self.assertIn(LEGIT, web("h1"))
def test_lax_max_length_lets_the_forged_more_specific_through(self):
scenarios.rov_a_lax()
self.wait_table("a")
self.wait(lambda: "24 - 25" in LAB.out("a", "vtysh", "-c", "show rpki prefix-table"))
scenarios.forged_specific()
self.wait(lambda: path_of("a", HALF) == "64666 64603")
self.assertEqual(self.rov_state("a", HALF), "valid")
self.assertIn(ATTACKER, web("h1"))
def test_partial_deployment_protects_only_those_behind_it(self):
scenarios.rov_d()
self.wait_table("d")
scenarios.specific()
self.wait(lambda: route("c", HALF) is not None)
self.assertFalse(wait_until(lambda: route("d", HALF) is not None, seconds=4))
self.assertIsNone(route("b", HALF))
self.assertIn(ATTACKER, web("h1"))
def test_rov_does_not_stop_a_leak(self):
scenarios.rov_a()
scenarios.rov_d()
self.wait_table("a")
self.wait_table("d")
scenarios.leak()
self.wait(lambda: path_of("a", VICTIM) == "64604 64620 64603")
self.assertEqual(self.rov_state("a", VICTIM), "valid")
class F_Filters(Scenario):
"""Paso 5 del laboratorio. Se salta hasta que completen lab/filters-*.conf."""
def setUp(self):
answer_path("filters-a.conf")
answer_path("filters-b.conf")
super().setUp()
scenarios.filters(ANSWERS)
self.assertTrue(wait_until(baseline, seconds=30), "los filtros rompieron rutas legítimas")
def assert_attacker_kept_out(self):
self.assertFalse(wait_until(lambda: ATTACKER in web("h1"), seconds=4))
self.assertEqual(origin("a", VICTIM), "64603")
self.assertIsNone(route("a", HALF))
self.assertIn(LEGIT, web("h1"))
def test_legitimate_prefixes_still_pass(self):
for node, prefix, asn in (("a", "100.64.1.0/24", "64601"), ("a", "100.64.66.0/24", "64666"),
("a", "100.64.4.0/24", "64604"), ("b", "100.64.3.0/24", "64603"),
("b", "100.64.4.0/24", "64604")):
with self.subTest(node=node, prefix=prefix):
self.assertEqual(origin(node, prefix), asn)
def test_same_prefix_is_rejected(self):
scenarios.same()
self.assertTrue(wait_until(lambda: route("r6", VICTIM) is not None))
self.assert_attacker_kept_out()
def test_more_specific_is_rejected(self):
scenarios.specific()
self.assertTrue(wait_until(lambda: route("r6", HALF) is not None))
self.assert_attacker_kept_out()
def test_forged_origin_is_rejected(self):
scenarios.forged()
self.assertTrue(wait_until(lambda: route("r6", VICTIM) is not None))
self.assert_attacker_kept_out()
def test_more_specific_of_the_own_block_is_rejected(self):
scenarios.conf("r6", "ip route 100.64.66.0/25 Null0",
*scenarios.in_bgp(64666, "network 100.64.66.0/25"))
self.addCleanup(lambda: (scenarios.conf("r6", *scenarios.in_bgp(64666, "no network 100.64.66.0/25")),
scenarios.conf("r6", "no ip route 100.64.66.0/25 Null0")))
self.assertTrue(wait_until(lambda: route("r6", "100.64.66.0/25") is not None))
self.assertFalse(wait_until(lambda: route("a", "100.64.66.0/25") is not None, seconds=4))
def test_leak_is_rejected_on_both_sides(self):
scenarios.leak()
self.assertFalse(wait_until(lambda: "64604" in (path_of("b", "100.64.1.0/24") or ""),
seconds=4))
self.assertNotIn("64604", path_of("a", VICTIM))
self.assertNotIn("10.207.7.1", trace("srv", "100.64.1.10"))
if __name__ == "__main__":
unittest.main()"""Pruebas del peering por el IXP en ixp/ixp.clab.yml."""
import os
import re
import subprocess
import time
import unittest
from netlab import HERE, Lab, wait_until
LAB = Lab("ixp", "w07-ixp", "redes-w07-ixp")
H2, H3 = "100.64.72.10", "100.64.73.10"
PEERING = os.path.join(HERE, "ixp", "peering.sh")
def setUpModule():
unittest.addModuleCleanup(LAB.destroy)
LAB.deploy()
if not wait_until(lambda: LAB.sh("h1", "ping", "-c", "1", "-W", "2", H2).returncode == 0
and LAB.sh("h1", "ping", "-c", "1", "-W", "2", H3).returncode == 0, seconds=60):
raise RuntimeError("la topología no quedó lista")
def peering(state):
done = subprocess.run(["sh", PEERING, state], capture_output=True, text=True)
if done.returncode != 0:
raise RuntimeError(done.stdout + done.stderr)
def rtt(node, address):
output = LAB.out(node, "ping", "-c", "5", "-i", "0.2", address)
return float(re.search(r"= [\d.]+/([\d.]+)/", output).group(1))
def path_to(node, address):
return LAB.out(node, "traceroute", "-n", "-w", "1", address)
def transit_bytes():
text = LAB.out("t", "cat", "/sys/class/net/eth1/statistics/rx_bytes")
return int(text.strip())
def send_mbytes(megabytes=5):
LAB.daemon("h2", "iperf3 -s -1 >/dev/null 2>&1")
time.sleep(0.5)
before = transit_bytes()
done = LAB.sh("h1", "iperf3", "-c", H2, "-n", f"{megabytes}M", timeout=120)
if done.returncode != 0:
raise RuntimeError(done.stdout + done.stderr)
time.sleep(1) # lo último que se envió todavía viene en camino por el enlace con retardo
return transit_bytes() - before
def peer_routes():
return "100.64.72.0/24" in LAB.out("isp1", "ip", "route", "get", H2) \
or "10.208.100.2" in LAB.out("isp1", "ip", "route", "get", H2)
class Peering(unittest.TestCase):
def setUp(self):
peering("off")
self.addCleanup(peering, "off")
self.assertTrue(wait_until(lambda: not peer_routes(), seconds=20))
self.assertTrue(wait_until(lambda: LAB.sh("h1", "ping", "-c", "1", "-W", "2", H2).returncode == 0))
def test_without_peering_local_traffic_goes_through_the_far_provider(self):
self.assertIn("10.208.1.2", path_to("h1", H2))
self.assertGreater(rtt("h1", H2), 120)
def test_peering_shortens_the_path_and_keeps_the_rest(self):
peering("on")
self.assertTrue(wait_until(peer_routes, seconds=30))
path = path_to("h1", H2)
self.assertNotIn("10.208.1.2", path)
self.assertIn("10.208.100.2", path)
self.assertLess(rtt("h1", H2), 40)
# isp3 no hizo peering: con él sigue todo igual.
self.assertIn("10.208.1.2", path_to("h1", H3))
self.assertGreater(rtt("h1", H3), 120)
def test_traffic_leaves_the_paid_link(self):
before = send_mbytes()
self.assertGreater(before, 4_000_000)
peering("on")
self.assertTrue(wait_until(peer_routes, seconds=30))
self.assertLess(send_mbytes(), 500_000)
def test_peer_does_not_get_free_transit(self):
peering("on")
self.assertTrue(wait_until(peer_routes, seconds=30))
offered = LAB.out("isp1", "vtysh", "-c",
"show bgp ipv4 unicast neighbors 10.208.100.2 advertised-routes")
self.assertIn("100.64.71.0/24", offered)
self.assertNotIn("100.64.73.0/24", offered)
def test_if_the_ixp_fails_traffic_returns_to_transit(self):
peering("on")
self.assertTrue(wait_until(peer_routes, seconds=30))
self.addCleanup(LAB.out, "ix", "ip", "link", "set", "eth2", "up")
LAB.out("ix", "ip", "link", "set", "eth2", "down")
self.assertTrue(wait_until(lambda: not peer_routes(), seconds=25))
self.assertTrue(wait_until(lambda: LAB.sh("h1", "ping", "-c", "1", "-W", "2", H2).returncode == 0))
self.assertGreater(rtt("h1", H2), 120)
LAB.out("ix", "ip", "link", "set", "eth2", "up")
self.assertTrue(wait_until(peer_routes, seconds=40))
self.assertLess(rtt("h1", H2), 40)
if __name__ == "__main__":
unittest.main()"""Pruebas de rov.c contra los casos del RFC 6811, y del cache RTR contra el RFC 8210.
Ninguna necesita una topología.
"""
import os
import socket
import struct
import subprocess
import sys
import time
import unittest
from netlab import HERE
EXAMPLE = os.path.join(HERE, "vrps-example.txt")
def rov(prefix, asn, vrps=EXAMPLE):
done = subprocess.run([os.path.join(HERE, "rov"), vrps, prefix, str(asn)],
capture_output=True, text=True)
return done.returncode, done.stdout.strip().splitlines()[-1] if done.stdout else ""
class Classification(unittest.TestCase):
def test_guide_cases(self):
# Un ROA autoriza al AS 64600 a originar 198.51.100.0/24 con largo máximo 24.
for prefix, asn, state in (("198.51.100.0/24", 64600, "valid"),
("198.51.100.0/24", 64666, "invalid"),
("198.51.100.0/25", 64600, "invalid"),
("198.51.100.0/25", 64666, "invalid"),
("198.51.0.0/16", 64600, "not found"),
("198.51.101.0/24", 64600, "not found")):
with self.subTest(prefix=prefix, asn=asn):
self.assertEqual(rov(prefix, asn), (0, state))
def test_max_length_allows_more_specifics_up_to_it(self):
self.assertEqual(rov("203.0.113.128/25", 64601), (0, "valid"))
self.assertEqual(rov("203.0.113.128/26", 64601), (0, "invalid"))
def test_as_zero_never_matches(self):
self.assertEqual(rov("192.0.2.0/24", 0), (0, "invalid"))
def test_one_matching_vrp_is_enough(self):
self.assertEqual(rov("10.0.0.0/8", 64701), (0, "valid"))
self.assertEqual(rov("10.1.0.0/16", 64700), (0, "valid"))
# La segunda VRP cubre el /16, pero no lo autoriza: basta con la primera.
self.assertEqual(rov("10.1.0.0/16", 64701), (0, "invalid"))
def test_bad_arguments_are_rejected(self):
for prefix, asn in (("10.0.0.0", 1), ("10.0.0.0/33", 1), ("x/8", 1), ("10.0.0.0/8", "y")):
with self.subTest(prefix=prefix, asn=asn):
self.assertEqual(rov(prefix, asn)[0], 2)
class RtrCache(unittest.TestCase):
"""Un router que habla RTR a mano: mismos bytes que manda FRRouting."""
def setUp(self):
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
self.port = probe.getsockname()[1]
self.cache = subprocess.Popen(
[sys.executable, os.path.join(HERE, "bgp", "cache", "rtr_cache.py"),
os.path.join(HERE, "bgp", "cache", "vrps.txt"), "--port", str(self.port)],
stdout=subprocess.DEVNULL)
self.addCleanup(self.cache.wait)
self.addCleanup(self.cache.kill)
deadline = time.monotonic() + 5
while True:
try:
self.conn = socket.create_connection(("127.0.0.1", self.port), timeout=3)
break
except OSError:
if time.monotonic() > deadline:
raise
time.sleep(0.05)
self.addCleanup(self.conn.close)
def read_pdu(self):
data = b""
while len(data) < 8:
data += self.conn.recv(8 - len(data))
version, kind, field, length = struct.unpack("!BBHI", data)
while len(data) < length:
data += self.conn.recv(length - len(data))
return version, kind, field, data
def test_reset_query_gets_the_whole_table(self):
self.conn.sendall(struct.pack("!BBHI", 1, 2, 0, 8))
version, kind, session, _ = self.read_pdu()
self.assertEqual((version, kind), (1, 3))
prefixes = []
while True:
_, kind, field, data = self.read_pdu()
if kind == 7:
break
self.assertEqual((kind, len(data)), (4, 20))
_, length, max_length, _, address, asn = struct.unpack("!BBBB4sI", data[8:])
prefixes.append((socket.inet_ntoa(address), length, max_length, asn))
self.assertEqual(field, session)
self.assertEqual(len(data), 24)
self.assertIn(("100.64.3.0", 24, 24, 64603), prefixes)
self.assertIn(("100.64.1.0", 24, 24, 64601), prefixes)
def test_version_zero_is_answered_in_version_zero(self):
self.conn.sendall(struct.pack("!BBHI", 0, 2, 0, 8))
version = kind = None
while kind != 7:
version, kind, _, data = self.read_pdu()
self.assertEqual(version, 0)
self.assertEqual(len(data), 12)
def test_unknown_version_gets_an_error_report(self):
self.conn.sendall(struct.pack("!BBHI", 9, 2, 0, 8))
_, kind, code, _ = self.read_pdu()
self.assertEqual((kind, code), (10, 4))
if __name__ == "__main__":
unittest.main()Laboratorio
Archivos de partida del laboratorio de la semana.
! Filtros de a (AS 64610), en sintaxis de vtysh: el archivo se carga con vtysh -f.
! Paso 5 del laboratorio: que a acepte de cada cliente solo lo que le corresponde.
!
! cliente sesión bloque asignado nombre de la lista
! r1 10.207.4.1 100.64.1.0/24 CLIENTE-R1
! r4 10.207.6.1 100.64.4.0/24 CLIENTE-R4
! r6 10.207.5.1 100.64.66.0/24 CLIENTE-R6
!
! Usen esos nombres: `scenarios.py reset` los conoce y los borra.
! TODO: escriban las listas y aplíquenlas de entrada a cada sesión de cliente.! Filtros de b (AS 64620), en sintaxis de vtysh: el archivo se carga con vtysh -f.
!
! cliente sesión bloque asignado nombre de la lista
! r3 10.207.8.1 100.64.3.0/24 CLIENTE-R3
! r4 10.207.7.1 100.64.4.0/24 CLIENTE-R4
!
! TODO: lo mismo que en filters-a.conf.Compilación
Cómo se compilan y ejecutan los ejemplos.
# Semana 7: los problemas de BGP y la estructura de Internet. Se usa dentro del
# contenedor del curso.
#
# make compila rov
# make test compila, levanta las dos topologías una tras otra y corre las
# pruebas (test_*.py); siempre destruye lo que levantó. La prueba
# de los filtros usa lab/ y se salta hasta que la completen
# make up levanta los seis AS con el atacante (bgp/bgp.clab.yml)
# make up-ixp levanta los tres ISP con el IXP (ixp/ixp.clab.yml)
# make down destruye las dos topologías y sus redes de Docker
# make clean lo anterior, y borra los binarios
CC = gcc
CFLAGS = -Wall -Wextra -std=c17 -D_DEFAULT_SOURCE -O2 -g
.PHONY: all test up up-ixp down clean
all: rov
rov: rov.c
$(CC) $(CFLAGS) -o $@ $<
test: all
@status=0; PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -p 'test_*.py' -v || status=$$?; \
$(MAKE) --no-print-directory down; exit $$status
up:
cd bgp && containerlab deploy -t bgp.clab.yml --reconfigure
up-ixp:
cd ixp && containerlab deploy -t ixp.clab.yml --reconfigure
down:
@cd bgp && containerlab destroy -t bgp.clab.yml --cleanup >/dev/null 2>&1 || true
@cd ixp && containerlab destroy -t ixp.clab.yml --cleanup >/dev/null 2>&1 || true
@docker network rm redes-w07-bgp >/dev/null 2>&1 || true
@docker network rm redes-w07-ixp >/dev/null 2>&1 || true
clean: down
rm -f rov
rm -rf __pycache__ bgp/clab-w07-bgp ixp/clab-w07-ixp