Эксплуатация MIME Sniffing

В каждом запросе обычно есть заголовок ответа, называемый Content-Type, который сообщает браузеру MIME-тип ответа, такой как text/html или application/json.

Но что, если заголовка Content-Type нет? Браузер попытается определить тип на основе содержимого файла. Даже если заголовок Content-Type присутствует, браузер все равно может интерпретировать его как другой тип.

Это поведение, при котором MIME-тип выводится из содержимого файла, называется MIME sniffing. Давайте вместе исследуем эту функцию!

MIME Sniffing 101

Мы можем легко отправить ответ без заголовка Content-Type, используя Express:

const express = require('express');
const app = express();
app.get('/', (req, res) => {
  res.write('<h1>hello</h1>')
  res.end()
});
app.listen(5555, () => {
  console.log('Server is running on port 5555');
});

Если вы откроете эту веб-страницу в браузере, вы увидите, что текст "hello" становится больше и жирным, что указывает на то, что браузер отображает ответ как веб-страницу:

Теперь давайте посмотрим на второй пример, где мы изменяем <h1> на <h2>:

const express = require('express');const app = express();app.get('/', (req, res) => {  res.write('<h2>hello</h2>')  res.end()});app.listen(5555, () => {  console.log('Server is ruconst express = require('express');
const app = express();

app.get('/', (req, res) => {
  res.write('<h2>hello</h2>');
  res.end();
});

app.listen(5555, () => {
  console.log('Server is running on port 5555');
});
nning on port 5555');});

Почему это вдруг отображается как простой текст?

Теперь в третьем примере у нас есть тот же <h1>, но с дополнительным текстом:

const express = require('express');
const app = express();
app.get('/', (req, res) => {
  res.write('Hello, <h1>world</h1>');
  res.end();
});
app.listen(5555, () => {
  console.log('Server is running on port 5555');
});

Снова он отображается как простой текст вместо HTML.

Вы можете подумать, что механизм MIME sniffing в браузере — это загадка, как черный ящик, о том, как он работает. К счастью, мы тестируем в Chrome, и Chromium имеет открытый исходный код.

Код, отвечающий за MIME sniffing в Chromium, можно найти в файле net/base/mime_sniffer.cc. В начале кода объясняется, как это работает:

// Detecting mime types is a tricky business because we need to balance
// compatibility concerns with security issues.  Here is a survey of how other
// browsers behave and then a description of how we intend to behave.  
// 
// HTML payload, no Content-Type header:
// * IE 7: Render as HTML
// * Firefox 2: Render as HTML
// * Safari 3: Render as HTML
// * Opera 9: Render as HTML
// 
// Here the choice seems clear:
// => Chrome: Render as HTML
// 
// HTML payload, Content-Type: "text/plain":
// * IE 7: Render as HTML
// * Firefox 2: Render as text
// * Safari 3: Render as text (Note: Safari will Render as HTML if the URL
//                                   has an HTML extension)
// * Opera 9: Render as text
// 
// Here we choose to follow the majority (and break some compatibility with IE).
// Many folks dislike IE's behavior here.
// => Chrome: Render as text
// 
// We generalize this as follows.  If the Content-Type header is text/plain
// we won't detect dangerous mime types (those that can execute script).
// 
// HTML payload, Content-Type: "application/octet-stream":
// * IE 7: Render as HTML
// * Firefox 2: Download as application/octet-stream
// * Safari 3: Render as HTML
// * Opera 9: Render as HTML
// 
// We follow Firefox.
// => Chrome: Download as application/octet-stream
// One factor in this decision is that IIS 4 and 5 will send
// application/octet-stream for .xhtml files (because they don't recognize
// the extension).  We did some experiments and it looks like this doesn't occur
// very often on the web.  We choose the more secure option.

Итак, что заставляет его считать полезную нагрузку "HTML payload"? Это проверяется дальше в коде:

// Our HTML sniffer differs slightly from Mozilla.  For example, Mozilla will
// decide that a document that begins "<!DOCTYPE SOAP-ENV:Envelope PUBLIC " is
// HTML, but we will not.
#define MAGIC_HTML_TAG(tag) \
  MAGIC_STRING("text/html", "<" tag)
static const MagicNumber kSniffableTags[] = {
  // XML processing directive.  Although this is not an HTML mime type, we sniff
  // for this in the HTML phase because text/xml is just as powerful as HTML and
  // we want to leverage our white space skipping technology.
  MAGIC_NUMBER("text/xml", "<?xml"),
  // Mozilla
  // DOCTYPEs
  MAGIC_HTML_TAG("!DOCTYPE html"),
  // HTML5 spec
  // Sniffable tags, ordered by how often they occur in sniffable documents.
  MAGIC_HTML_TAG("script"),
  // HTML5 spec, Mozilla
  MAGIC_HTML_TAG("html"),
  // HTML5 spec, Mozilla
  MAGIC_HTML_TAG("!--"),
  MAGIC_HTML_TAG("head"),
  // HTML5 spec, Mozilla
  MAGIC_HTML_TAG("iframe"),
  // Mozilla
  MAGIC_HTML_TAG("h1"),
  // Mozilla
  MAGIC_HTML_TAG("div"),
  // Mozilla
  MAGIC_HTML_TAG("font"),
  // Mozilla
  MAGIC_HTML_TAG("table"),
  // Mozilla
  MAGIC_HTML_TAG("a"),
  // Mozilla
  MAGIC_HTML_TAG("style"),
  // Mozilla
  MAGIC_HTML_TAG("title"),
  // Mozilla
  MAGIC_HTML_TAG("b"),
  // Mozilla
  MAGIC_HTML_TAG("body"),
  // Mozilla
  MAGIC_HTML_TAG("br"),
  MAGIC_HTML_TAG("p"),
  // Mozilla
};
// ...
// Returns true and sets result if the content appears to be HTML.
// Clears have_enough_content if more data could possibly change the result.
static bool SniffForHTML(base::StringPiece content,
                         bool*

Он проверяет, соответствует ли строка в начале ответа, после удаления пробелов, перечисленным выше шаблонам HTML. Общие начала HTML, такие как <!DOCTYPE html и <html, включены в этот список. Это также объясняет, почему только пример <h1>hello</h1> отображается как HTML в наших предыдущих тестах.

Согласно исходному коду, похоже, что Chromium не учитывает расширение файла или другие факторы в URL. Он полностью полагается на содержимое файла. Давайте проведем еще один тест, чтобы это подтвердить:

const express = require('express');
const app = express();
app.get('/test.html', (req, res) => {
  res.write('abcde<h1>test</h1>');
  res.end();
});
app.listen(5555, () => {
  console.log('Server is running on port 5555');
});

Я не буду включать изображение здесь, но независимо от того, что URL — это test.html, результат все равно отображается как простой текст. А как насчет других браузеров? Давайте попробуем открыть его в Firefox:

Мы можем увидеть, что Firefox действительно отображает его как HTML! Следовательно, мы можем сделать вывод, что Firefox учитывает расширение файла в URL при выполнении MIME sniffing.

Exploiting MIME Sniffing for Attacks

Из нашего предыдущего исследования мы подтвердили факт: если у ответа нет установленного Content-Type и мы можем контролировать содержимое, мы можем использовать MIME sniffing, чтобы заставить браузер интерпретировать файл как веб-страницу.

Например, предположим, что есть функция загрузки изображений, которая проверяет только расширение файла, но не содержимое. Мы можем загрузить файл с именем a.png, но содержимое будет <script>alert(1)</script>. Если сервер не добавляет автоматически Content-Type при обслуживании этого изображения, это становится уязвимостью XSS.

Однако большинство серверов в настоящее время автоматически добавляют Content-Type. Возможно ли это все еще?

Да, мы можем объединить это с другими незначительными проблемами.

Apache HTTP Server — это широко используемый сервер, и знаменитый стек LAMP (Linux + Apache + MySQL + PHP) использует этот сервер.

Apache HTTP Server имеет своеобразное поведение: если имя файла содержит только точку (.), он не выведет Content-Type. Например, a.png автоматически определит MIME-тип на основе расширения файла и выведет image/png, но если имя файла будет ..png, он не выведет Content-Type.

Таким образом, если бэкенд использует Apache HTTP Server для обработки загрузок файлов, мы можем загрузить, казалось бы, валидное изображение с расширением ..png, но при открытии в браузере оно будет отображаться как веб-страница, становясь уязвимостью XSS.

Согласно Apache HTTP Server, это ожидаемое поведение, и вы можете обратиться к @YNizry для получения дополнительных деталей.

Content Types that can execute JavaScript

Кроме HTML-файлов, какие еще типы файлов могут выполнять JavaScript?

В исследовании, проведенном BlackFan в 2020 году: Content-Type Research предоставлен исчерпывающий список: Content-Type that can be used for XSS.

Из списка мы видим, что кроме самых распространенных типов контента HTML, XML и SVG есть и другие, которые могут выполнять JavaScript.

Особый интерес представляют файлы SVG, потому что многие веб-сайты имеют функциональность загрузки изображений, и SVG считается форматом изображения. Поэтому некоторые веб-сайты разрешают загрузку SVG. Однако из этого исследования мы можем узнать, что разрешение загрузки SVG эквивалентно разрешению загрузки HTML, поскольку SVG может выполнять JavaScript!

Например, febin сообщил о уязвимости в программном обеспечении с открытым исходным кодом Mantis Bug Tracker в 2022 году: CVE-2022-33910: Stored XSS via SVG file upload. Эта уязвимость возникла потому, что пользователи могли загружать файлы при создании нового репорта, и разрешенный формат файла был SVG. Поэтому файл SVG мог быть загружен, и когда другие пользователи открывали его, скрытый код внутри выполнялся.

Content Types that can be loaded as scripts

Возьмем следующий фрагмент кода в качестве примера:

<script src="URL"></script>

Вы когда-нибудь задумывались, какой должен быть тип контента URL, чтобы браузер загрузил его как скрипт?

Например, если это image/png, это не сработает, и вы увидите следующее сообщение об ошибке в браузере:

Refused to execute script from 'http://localhost:5555/js' because its MIME type ('image/png') is not executable.

Наиболее распространенный тип, text/javascript, очевидно, работает нормально. Но есть ли другие?

Из десяти перечисленных ниже типов контента только два из них не сработают. Можете угадать, какие два?

  1. application/zip

  2. application/json

  3. application/octet-stream

  4. text/csv

  5. text/html

  6. text/json

  7. text/plain

  8. huli/blog

  9. video/mp4

  10. font/woff2

Ответ будет раскрыт позже, но сначала давайте рассмотрим "действительные MIME-типы JavaScript", указанные в исходном коде Chromium: /third_party/blink/common/mime_util/mime_util.cc:

//  Support every script type mentioned in the spec, as it notes that "User
//  agents must recognize all JavaScript MIME types." See
//  https://html.spec.whatwg.org/#javascript-mime-type.
const char* const kSupportedJavascriptTypes[] = {
    "application/ecmascript",
    "application/javascript",
    "application/x-ecmascript",
    "application/x-javascript",
    "text/ecmascript",
    "text/javascript",
    "text/javascript1.0",
    "text/javascript1.1",
    "text/javascript1.2",
    "text/javascript1.3",
    "text/javascript1.4",
    "text/javascript1.5",
    "text/jscript",
    "text/livescript",
    "text/x-ecmascript",
    "text/x-javascript",
};

Все вышеперечисленные — это действительные MIME-типы JavaScript, и вы можете увидеть множество типов из прошлого, таких как jscript или livescript.

Кроме действительных MIME-типов JavaScript, согласно спецификации, есть только четыре типа, которые не сработают:

  1. audio/*

  2. image/*

  3. video/*

  4. text/csv

Кроме этих, все остальные типы действительны. Таким образом, среди упомянутых выше вариантов только text/csv и video/mp4 не сработают, в то время как остальные будут работать! Да, даже text/html и application/json будут работать, и даже xss/blog будет работать.

Если вы хотите ужесточить этот механизм и разрешить загрузку только MIME-типов JavaScript, вы можете добавить заголовок в ответ: X-Content-Type-Options: nosniff. После добавления этого заголовка ни один из 10 ранее упомянутых примеров не будет работать, и вы увидите следующее сообщение об ошибке при загрузке:

Refused to execute script from 'http://localhost:5555/js' because its MIME type ('text/plain') is not executable, and strict MIME type checking is enabled.

strict MIME type это функция, включаемая добавлением этого заголовка.

То же самое касается таблиц стилей. После включения этой функции только MIME-тип text/css будет признан действительным, и все остальные приведут к ошибке.

Итак, что произойдет, если вы продолжите включать эту функцию? Это создаст дополнительный риск безопасности.

Предположим, вы случайно нашли уязвимость XSS на веб-сайте. Однако проблема в том, что CSP веб-сайта — это script-src 'self';, что означает, что он не позволяет загружать внешние скрипты, и встроенные скрипты также заблокированы. Как вы можете обойти CSP в этом случае?

Если веб-сайт предоставляет функцию загрузки файлов, которая принимает файлы, отличные от изображений, видео и CSV-файлов, и не проверяет содержимое, предположим, что он принимает только ZIP-файлы, тогда вы можете загрузить файл, содержащий код JavaScript.

Таким образом, вы можете использовать <script src="/uploads/files/my.zip"></script>, чтобы загрузить скрипт и успешно обойти CSP. Причина, по которой это работает, заключается в поведении, упомянутом ранее — пока MIME-тип не является одним из тех немногих типов, его можно загрузить как скрипт.

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

Заключение

В этой статье мы рассмотрели множество интересных аспектов, связанных с MIME-типами, а также изучили много исходного кода Chromium. Лично я считаю, что исходный код Chromium написан очень понятно, включает комментарии и ссылки на спецификации, поэтому вам не нужно отдельно искать спецификации. Это как убить двух зайцев одним выстрелом. Наконец, мы поняли цель и функцию заголовка X-Content-Type-Options: nosniff. Я уверен, что многие люди видели этот заголовок раньше, но не знали, для чего он нужен, и теперь вы это знаете.

Дополнительные ссылки:

Last updated