sql >> Base de Datos >  >> RDS >> Database

Prevención de ataques de inyección SQL con Python

Cada pocos años, el Proyecto de seguridad de aplicaciones web abiertas (OWASP) clasifica los riesgos de seguridad de aplicaciones web más críticos. Desde el primer informe, los riesgos de inyección siempre han estado en la cima. Entre todos los tipos de inyección, inyección SQL es uno de los vectores de ataque más comunes y posiblemente el más peligroso. Como Python es uno de los lenguajes de programación más populares del mundo, es fundamental saber cómo protegerse contra la inyección SQL de Python.

En este tutorial, aprenderá:

  • Qué inyección Python SQL es y cómo prevenirlo
  • Cómo redactar consultas con literales e identificadores como parámetros
  • Cómo ejecutar consultas de forma segura en una base de datos

Este tutorial es adecuado para usuarios de todos los motores de bases de datos . Los ejemplos aquí usan PostgreSQL, pero los resultados se pueden reproducir en otros sistemas de administración de bases de datos (como SQLite, MySQL, Microsoft SQL Server, Oracle, etc.).

Bonificación gratuita: 5 pensamientos sobre el dominio de Python, un curso gratuito para desarrolladores de Python que le muestra la hoja de ruta y la mentalidad que necesitará para llevar sus habilidades de Python al siguiente nivel.


Comprender la inyección SQL de Python

Los ataques de inyección SQL son una vulnerabilidad de seguridad tan común que el legendario xkcd webcomic le dedicó un cómic:

La generación y ejecución de consultas SQL es una tarea común. Sin embargo, las empresas de todo el mundo suelen cometer errores terribles cuando se trata de redactar sentencias SQL. Mientras que la capa ORM normalmente compone consultas SQL, a veces tienes que escribir las tuyas propias.

Cuando usa Python para ejecutar estas consultas directamente en una base de datos, existe la posibilidad de que pueda cometer errores que puedan comprometer su sistema. En este tutorial, aprenderá cómo implementar con éxito funciones que componen consultas SQL dinámicas sin poniendo su sistema en riesgo de inyección Python SQL.



Configuración de una base de datos

Para comenzar, configurará una base de datos PostgreSQL nueva y la llenará con datos. A lo largo del tutorial, usará esta base de datos para presenciar de primera mano cómo funciona la inyección SQL de Python.


Crear una base de datos

Primero, abra su shell y cree una nueva base de datos PostgreSQL propiedad del usuario postgres :

$ createdb -O postgres psycopgtest

Aquí usó la opción de línea de comando -O para establecer el propietario de la base de datos para el usuario postgres . También especificó el nombre de la base de datos, que es psycopgtest .

Nota: postgres es un usuario especial , que normalmente reservarías para tareas administrativas, pero para este tutorial, está bien usar postgres . Sin embargo, en un sistema real, debe crear un usuario independiente para que sea el propietario de la base de datos.

¡Tu nueva base de datos está lista para funcionar! Puedes conectarte usando psql :

$ psql -U postgres -d psycopgtest
psql (11.2, server 10.5)
Type "help" for help.

Ahora estás conectado a la base de datos psycopgtest como usuario postgres . Este usuario también es el propietario de la base de datos, por lo que tendrá permisos de lectura en todas las tablas de la base de datos.



Crear una tabla con datos

A continuación, debe crear una tabla con información del usuario y agregarle datos:

psycopgtest=# CREATE TABLE users (
    username varchar(30),
    admin boolean
);
CREATE TABLE

psycopgtest=# INSERT INTO users
    (username, admin)
VALUES
    ('ran', true),
    ('haki', false);
INSERT 0 2

psycopgtest=# SELECT * FROM users;
 username | admin
----------+-------
 ran      | t
 haki     | f
(2 rows)

La tabla tiene dos columnas:username y admin . El admin La columna indica si un usuario tiene o no privilegios administrativos. Tu objetivo es dirigirte al admin e intentar abusar de él.



Configuración de un entorno virtual de Python

Ahora que tiene una base de datos, es hora de configurar su entorno de Python. Para obtener instrucciones paso a paso sobre cómo hacer esto, consulte Entornos virtuales de Python:un manual básico.

Cree su entorno virtual en un nuevo directorio:

(~/src) $ mkdir psycopgtest
(~/src) $ cd psycopgtest
(~/src/psycopgtest) $ python3 -m venv venv

Después de ejecutar este comando, un nuevo directorio llamado venv se creará. Este directorio almacenará todos los paquetes que instale dentro del entorno virtual.



Conexión a la base de datos

Para conectarse a una base de datos en Python, necesita un adaptador de base de datos . La mayoría de los adaptadores de bases de datos siguen la versión 2.0 de la especificación PEP 249 de la API de base de datos de Python. Cada motor de base de datos principal tiene un adaptador líder:

Base de datos Adaptador
PostgreSQL Psicopg
SQLite sqlite3
Oráculo cx_oracle
MySql MySQLdb

Para conectarse a una base de datos PostgreSQL, deberá instalar Psycopg, que es el adaptador más popular para PostgreSQL en Python. Django ORM lo usa de forma predeterminada y también es compatible con SQLAlchemy.

En tu terminal, activa el entorno virtual y usa pip para instalar psycopg :

(~/src/psycopgtest) $ source venv/bin/activate
(~/src/psycopgtest) $ python -m pip install psycopg2>=2.8.0
Collecting psycopg2
  Using cached https://....
  psycopg2-2.8.2.tar.gz
Installing collected packages: psycopg2
  Running setup.py install for psycopg2 ... done
Successfully installed psycopg2-2.8.2

Ahora está listo para crear una conexión a su base de datos. Aquí está el comienzo de su secuencia de comandos de Python:

import psycopg2

connection = psycopg2.connect(
    host="localhost",
    database="psycopgtest",
    user="postgres",
    password=None,
)
connection.set_session(autocommit=True)

Usaste psycopg2.connect() para crear la conexión. Esta función acepta los siguientes argumentos:

  • host es la dirección IP o el DNS del servidor donde se encuentra su base de datos. En este caso, el host es su máquina local o localhost .

  • database es el nombre de la base de datos a la que conectarse. Desea conectarse a la base de datos que creó anteriormente, psycopgtest .

  • user es un usuario con permisos para la base de datos. En este caso, desea conectarse a la base de datos como propietario, por lo que le pasa al usuario postgres .

  • password es la contraseña de quien haya especificado en user . En la mayoría de los entornos de desarrollo, los usuarios pueden conectarse a la base de datos local sin contraseña.

Después de configurar la conexión, configuró la sesión con autocommit=True . Activando autocommit significa que no tendrá que administrar manualmente las transacciones emitiendo un commit o rollback . Este es el comportamiento predeterminado en la mayoría de los ORM. También utiliza este comportamiento aquí para que pueda concentrarse en redactar consultas SQL en lugar de administrar transacciones.

Nota: Los usuarios de Django pueden obtener la instancia de la conexión utilizada por el ORM desde django.db.connection :

from django.db import connection


Ejecutar una consulta

Ahora que tiene una conexión a la base de datos, está listo para ejecutar una consulta:

>>>
>>> with connection.cursor() as cursor:
...     cursor.execute('SELECT COUNT(*) FROM users')
...     result = cursor.fetchone()
... print(result)
(2,)

Usaste la connection objeto para crear un cursor . Al igual que un archivo en Python, cursor se implementa como un administrador de contexto. Cuando creas el contexto, un cursor se abre para que lo use para enviar comandos a la base de datos. Cuando el contexto sale, el cursor se cierra y ya no puedes usarlo.

Nota: Para obtener más información sobre los administradores de contexto, consulte los administradores de contexto de Python y la declaración "with".

Mientras estaba dentro del contexto, usó cursor para ejecutar una consulta y obtener los resultados. En este caso, emitió una consulta para contar las filas en los users mesa. Para obtener el resultado de la consulta, ejecutó cursor.fetchone() y recibió una tupla. Dado que la consulta solo puede devolver un resultado, utilizó fetchone() . Si la consulta arrojara más de un resultado, entonces tendría que iterar sobre cursor o usa uno de los otros fetch* métodos.




Uso de parámetros de consulta en SQL

En la sección anterior, creó una base de datos, estableció una conexión con ella y ejecutó una consulta. La consulta que utilizó fue estática . En otras palabras, no tenía parámetros. . Ahora comenzará a usar parámetros en sus consultas.

Primero, implementará una función que verifique si un usuario es administrador o no. is_admin() acepta un nombre de usuario y devuelve el estado de administrador de ese usuario:

# BAD EXAMPLE. DON'T DO THIS!
def is_admin(username: str) -> bool:
    with connection.cursor() as cursor:
        cursor.execute("""
            SELECT
                admin
            FROM
                users
            WHERE
                username = '%s'
        """ % username)
        result = cursor.fetchone()
    admin, = result
    return admin

Esta función ejecuta una consulta para obtener el valor de admin columna para un nombre de usuario dado. Usaste fetchone() para devolver una tupla con un único resultado. Luego, desempaquetaste esta tupla en la variable admin . Para probar su función, verifique algunos nombres de usuario:

>>>
>>> is_admin('haki')
False
>>> is_admin('ran')
True

Hasta aquí todo bien. La función devolvió el resultado esperado para ambos usuarios. Pero, ¿qué pasa con el usuario inexistente? Echa un vistazo a este rastreo de Python:

>>>
>>> is_admin('foo')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 12, in is_admin
TypeError: cannot unpack non-iterable NoneType object

Cuando el usuario no existe, un TypeError es elevado. Esto se debe a que .fetchone() devuelve None cuando no se encuentran resultados y desempaquetar None genera un TypeError . El único lugar donde puede desempaquetar una tupla es donde rellena admin de result .

Para manejar usuarios no existentes, cree un caso especial para cuando result es None :

# BAD EXAMPLE. DON'T DO THIS!
def is_admin(username: str) -> bool:
    with connection.cursor() as cursor:
        cursor.execute("""
            SELECT
                admin
            FROM
                users
            WHERE
                username = '%s'
        """ % username)
        result = cursor.fetchone()

    if result is None:
        # User does not exist
        return False

    admin, = result
    return admin

Aquí, ha agregado un caso especial para manejar None . Si username no existe, entonces la función debería devolver False . Una vez más, pruebe la función en algunos usuarios:

>>>
>>> is_admin('haki')
False
>>> is_admin('ran')
True
>>> is_admin('foo')
False

¡Estupendo! La función ahora también puede manejar nombres de usuario que no existen.



Explotación de parámetros de consulta con inyección SQL de Python

En el ejemplo anterior, utilizó la interpolación de cadenas para generar una consulta. Luego, ejecutó la consulta y envió la cadena resultante directamente a la base de datos. Sin embargo, hay algo que puede haber pasado por alto durante este proceso.

Piense en el username argumento que pasaste a is_admin() . ¿Qué representa exactamente esta variable? Puede suponer que username es solo una cadena que representa el nombre de un usuario real. Sin embargo, como está a punto de ver, un intruso puede explotar fácilmente este tipo de descuido y causar un gran daño al realizar una inyección Python SQL.

Intente comprobar si el siguiente usuario es administrador o no:

>>>
>>> is_admin("'; select true; --")
True

Espera... ¿Qué acaba de pasar?

Echemos otro vistazo a la implementación. Imprima la consulta real que se está ejecutando en la base de datos:

>>>
>>> print("select admin from users where username = '%s'" % "'; select true; --")
select admin from users where username = ''; select true; --'

El texto resultante contiene tres declaraciones. Para comprender exactamente cómo funciona la inyección SQL de Python, debe inspeccionar cada parte individualmente. La primera declaración es la siguiente:

select admin from users where username = '';

Esta es su consulta prevista. El punto y coma (; ) finaliza la consulta, por lo que el resultado de esta consulta no importa. Lo siguiente es la segunda declaración:

select true;

Esta declaración fue construida por el intruso. Está diseñado para devolver siempre True .

Por último, verá este breve fragmento de código:

--'

Este fragmento desactiva todo lo que viene después. El intruso agregó el símbolo de comentario (-- ) para convertir todo lo que hayas puesto después del último marcador de posición en un comentario.

Cuando ejecuta la función con este argumento, siempre devolverá True . Si, por ejemplo, utiliza esta función en su página de inicio de sesión, un intruso podría iniciar sesión con el nombre de usuario '; select true; -- y se les otorgará acceso.

Si crees que esto es malo, ¡podría empeorar! Los intrusos con conocimiento de la estructura de su tabla pueden usar la inyección Python SQL para causar daños permanentes. Por ejemplo, el intruso puede inyectar una declaración de actualización para alterar la información en la base de datos:

>>>
>>> is_admin('haki')
False
>>> is_admin("'; update users set admin = 'true' where username = 'haki'; select true; --")
True
>>> is_admin('haki')
True

Vamos a desglosarlo de nuevo:

';

Este fragmento finaliza la consulta, al igual que en la inyección anterior. La siguiente declaración es la siguiente:

update users set admin = 'true' where username = 'haki';

Esta sección actualiza admin a true para el usuario haki .

Finalmente, está este fragmento de código:

select true; --

Como en el ejemplo anterior, esta pieza devuelve true y comenta todo lo que sigue.

¿Por qué es esto peor? Bueno, si el intruso logra ejecutar la función con esta entrada, entonces el usuario haki se convertirá en administrador:

psycopgtest=# select * from users;
 username | admin
----------+-------
 ran      | t
 haki     | t
(2 rows)

El intruso ya no tiene que usar el truco. Simplemente pueden iniciar sesión con el nombre de usuario haki . (Si el intruso realmente querían causar daño, incluso podrían emitir un DROP DATABASE comando.)

Antes de que te olvides, restaura haki volver a su estado original:

psycopgtest=# update users set admin = false where username = 'haki';
UPDATE 1

Entonces, ¿por qué sucede esto? Bueno, ¿qué sabes sobre el username ¿argumento? Sabe que debe ser una cadena que represente el nombre de usuario, pero en realidad no verifica ni hace cumplir esta afirmación. ¡Esto puede ser peligroso! Es exactamente lo que buscan los atacantes cuando intentan piratear su sistema.


Elaboración de parámetros de consulta seguros

En la sección anterior, vio cómo un intruso puede explotar su sistema y obtener permisos de administrador mediante el uso de una cadena cuidadosamente diseñada. El problema era que permitía que el valor pasado del cliente se ejecutara directamente en la base de datos, sin realizar ningún tipo de verificación o validación. Las inyecciones SQL se basan en este tipo de vulnerabilidad.

Cada vez que se utiliza la entrada del usuario en una consulta de base de datos, existe una posible vulnerabilidad para la inyección de SQL. La clave para evitar la inyección SQL de Python es asegurarse de que el valor se utilice como pretendía el desarrollador. En el ejemplo anterior, tenía la intención de username para ser utilizado como una cadena. En realidad, se usó como una instrucción SQL sin formato.

Para asegurarse de que los valores se utilicen según lo previsto, debe escape el valor. Por ejemplo, para evitar que los intrusos inyecten SQL sin procesar en lugar de un argumento de cadena, puede escapar las comillas:

>>>
>>> # BAD EXAMPLE. DON'T DO THIS!
>>> username = username.replace("'", "''")

Esto es sólo un ejemplo. Hay muchos caracteres especiales y escenarios en los que pensar cuando se intenta evitar la inyección SQL de Python. Por suerte para usted, los adaptadores de bases de datos modernos vienen con herramientas integradas para evitar la inyección SQL de Python mediante el uso de parámetros de consulta. . Estos se utilizan en lugar de la interpolación de cadenas simples para componer una consulta con parámetros.

Nota: Diferentes adaptadores, bases de datos y lenguajes de programación se refieren a los parámetros de consulta con nombres diferentes. Los nombres comunes incluyen variables de enlace , variables de reemplazo y variables de sustitución .

Ahora que comprende mejor la vulnerabilidad, está listo para reescribir la función usando parámetros de consulta en lugar de interpolación de cadenas:

 1def is_admin(username: str) -> bool:
 2    with connection.cursor() as cursor:
 3        cursor.execute("""
 4            SELECT
 5                admin
 6            FROM
 7                users
 8            WHERE
 9                username = %(username)s
10        """, {
11            'username': username
12        })
13        result = cursor.fetchone()
14
15    if result is None:
16        # User does not exist
17        return False
18
19    admin, = result
20    return admin

Esto es lo que es diferente en este ejemplo:

  • En la línea 9, usaste un parámetro con nombre username para indicar dónde debe ir el nombre de usuario. Observe cómo el parámetro username ya no está entre comillas simples.

  • En la línea 11, pasaste el valor de username como segundo argumento de cursor.execute() . La conexión utilizará el tipo y el valor de username al ejecutar la consulta en la base de datos.

Para probar esta función, pruebe algunos valores válidos e inválidos, incluida la cadena peligrosa de antes:

>>>
>>> is_admin('haki')
False
>>> is_admin('ran')
True
>>> is_admin('foo')
False
>>> is_admin("'; select true; --")
False

¡Increíble! La función devolvió el resultado esperado para todos los valores. Además, la cuerda peligrosa ya no funciona. Para comprender por qué, puede inspeccionar la consulta generada por execute() :

>>>
>>> with connection.cursor() as cursor:
...    cursor.execute("""
...        SELECT
...            admin
...        FROM
...            users
...        WHERE
...            username = %(username)s
...    """, {
...        'username': "'; select true; --"
...    })
...    print(cursor.query.decode('utf-8'))
SELECT
    admin
FROM
    users
WHERE
    username = '''; select true; --'

La conexión trató el valor de username como una cadena y escapó cualquier carácter que pudiera terminar la cadena e introducir la inyección Python SQL.



Pasar parámetros de consulta seguros

Los adaptadores de bases de datos suelen ofrecer varias formas de pasar parámetros de consulta. Marcadores de posición con nombre suelen ser los mejores para la legibilidad, pero algunas implementaciones pueden beneficiarse del uso de otras opciones.

Echemos un vistazo rápido a algunas de las formas correctas e incorrectas de usar los parámetros de consulta. El siguiente bloque de código muestra los tipos de consultas que querrá evitar:

# BAD EXAMPLES. DON'T DO THIS!
cursor.execute("SELECT admin FROM users WHERE username = '" + username + '");
cursor.execute("SELECT admin FROM users WHERE username = '%s' % username);
cursor.execute("SELECT admin FROM users WHERE username = '{}'".format(username));
cursor.execute(f"SELECT admin FROM users WHERE username = '{username}'");

Cada una de estas declaraciones pasa username del cliente directamente a la base de datos, sin realizar ningún tipo de comprobación o validación. Este tipo de código está listo para invitar a la inyección SQL de Python.

Por el contrario, estos tipos de consultas deberían ser seguras para ejecutar:

# SAFE EXAMPLES. DO THIS!
cursor.execute("SELECT admin FROM users WHERE username = %s'", (username, ));
cursor.execute("SELECT admin FROM users WHERE username = %(username)s", {'username': username});

En estas declaraciones, username se pasa como un parámetro con nombre. Ahora, la base de datos utilizará el tipo y el valor especificados de username al ejecutar la consulta, ofreciendo protección contra la inyección SQL de Python.




Uso de la composición SQL

Hasta ahora has usado parámetros para literales. Literales son valores como números, cadenas y fechas. Pero, ¿qué sucede si tiene un caso de uso que requiere redactar una consulta diferente, una en la que el parámetro es otra cosa, como un nombre de tabla o columna?

Inspirándonos en el ejemplo anterior, implementemos una función que acepte el nombre de una tabla y devuelva el número de filas en esa tabla:

# BAD EXAMPLE. DON'T DO THIS!
def count_rows(table_name: str) -> int:
    with connection.cursor() as cursor:
        cursor.execute("""
            SELECT
                count(*)
            FROM
                %(table_name)s
        """, {
            'table_name': table_name,
        })
        result = cursor.fetchone()

    rowcount, = result
    return rowcount

Intenta ejecutar la función en tu tabla de usuarios:

>>>
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 9, in count_rows
psycopg2.errors.SyntaxError: syntax error at or near "'users'"
LINE 5:                 'users'
                        ^

El comando no pudo generar el SQL. Como ya ha visto, el adaptador de la base de datos trata la variable como una cadena o un literal. Sin embargo, un nombre de tabla no es una cadena simple. Aquí es donde entra en juego la composición de SQL.

Ya sabe que no es seguro usar la interpolación de cadenas para componer SQL. Afortunadamente, Psycopg proporciona un módulo llamado psycopg.sql para ayudarlo a redactar consultas SQL de manera segura. Reescribamos la función usando psycopg.sql.SQL() :

from psycopg2 import sql

def count_rows(table_name: str) -> int:
    with connection.cursor() as cursor:
        stmt = sql.SQL("""
            SELECT
                count(*)
            FROM
                {table_name}
        """).format(
            table_name = sql.Identifier(table_name),
        )
        cursor.execute(stmt)
        result = cursor.fetchone()

    rowcount, = result
    return rowcount

Hay dos diferencias en esta implementación. Primero, usó sql.SQL() para redactar la consulta. Luego, usó sql.Identifier() para anotar el valor del argumento table_name . (Un identificador es un nombre de columna o tabla.)

Nota: Usuarios del popular paquete django-debug-toolbar podría obtener un error en el panel SQL para consultas compuestas con psycopg.sql.SQL() . Se espera una corrección para el lanzamiento en la versión 2.0.

Ahora, intente ejecutar la función en los users tabla:

>>>
>>> count_rows('users')
2

¡Estupendo! A continuación, veamos qué sucede cuando la tabla no existe:

>>>
>>> count_rows('foo')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 11, in count_rows
psycopg2.errors.UndefinedTable: relation "foo" does not exist
LINE 5:                 "foo"
                        ^

La función lanza la UndefinedTable excepción. En los siguientes pasos, usará esta excepción como una indicación de que su función está a salvo de un ataque de inyección SQL de Python.

Nota: La excepción UndefinedTable se agregó en psycopg2 versión 2.8. Si está trabajando con una versión anterior de Psycopg, obtendrá una excepción diferente.

Para ponerlo todo junto, agregue una opción para contar filas en la tabla hasta cierto límite. Esta característica puede ser útil para tablas muy grandes. Para implementar esto, agregue un LIMIT cláusula a la consulta, junto con los parámetros de consulta para el valor del límite:

from psycopg2 import sql

def count_rows(table_name: str, limit: int) -> int:
    with connection.cursor() as cursor:
        stmt = sql.SQL("""
            SELECT
                COUNT(*)
            FROM (
                SELECT
                    1
                FROM
                    {table_name}
                LIMIT
                    {limit}
            ) AS limit_query
        """).format(
            table_name = sql.Identifier(table_name),
            limit = sql.Literal(limit),
        )
        cursor.execute(stmt)
        result = cursor.fetchone()

    rowcount, = result
    return rowcount

En este bloque de código, anotó limit usando sql.Literal() . Como en el ejemplo anterior, psycopg vinculará todos los parámetros de consulta como literales cuando se utilice el enfoque simple. Sin embargo, al usar sql.SQL() , debe anotar explícitamente cada parámetro usando sql.Identifier() o sql.Literal() .

Nota: Desafortunadamente, la especificación de la API de Python no aborda la vinculación de identificadores, solo literales. Psycopg es el único adaptador popular que agregó la capacidad de componer SQL de manera segura con literales e identificadores. Este hecho hace que sea aún más importante prestar mucha atención al vincular identificadores.

Ejecute la función para asegurarse de que funciona:

>>>
>>> count_rows('users', 1)
1
>>> count_rows('users', 10)
2

Ahora que ve que la función está funcionando, asegúrese de que también sea segura:

>>>
>>> count_rows("(select 1) as foo; update users set admin = true where name = 'haki'; --", 1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 18, in count_rows
psycopg2.errors.UndefinedTable: relation "(select 1) as foo; update users set admin = true where name = '" does not exist
LINE 8:                     "(select 1) as foo; update users set adm...
                            ^

Este rastreo muestra que psycopg escapó del valor y la base de datos lo trató como un nombre de tabla. Dado que no existe una tabla con este nombre, una UndefinedTable ¡Se generó una excepción y no fuiste pirateado!



Conclusión

Ha implementado con éxito una función que compone SQL dinámico sin ¡poniendo su sistema en riesgo de inyección Python SQL! Ha utilizado literales e identificadores en su consulta sin comprometer la seguridad.

Has aprendido:

  • Qué inyección Python SQL es y cómo se puede explotar
  • Cómo prevenir la inyección SQL de Python usando parámetros de consulta
  • Cómo componer sentencias SQL de forma segura que usan literales e identificadores como parámetros

Ahora puede crear programas que pueden resistir ataques desde el exterior. ¡Avanza y frustra a los piratas informáticos!