Security Report CodePrizm

Generated by CodePrizm

Security Audit Report: Paramiko SSH Library

Executive Summary

This security audit examines Paramiko version 4.0.0, a Python SSH2 protocol library. As a cryptographic library handling authentication and secure communications, Paramiko has a significant attack surface and requires careful security analysis.

Overall Security Posture: The codebase demonstrates mature security practices in most areas, with proper use of cryptographic libraries (cryptography, bcrypt, pynacl) and careful handling of SSH protocol implementation. However, several security concerns were identified:

Key Risk Areas:

1. Hardcoded Credentials in Demo Code: Multiple demo files contain hardcoded passwords and weak authentication checks

2. Command Injection Vulnerabilities: SSH config parsing executes shell commands without proper sanitization

3. Weak Cryptographic Practices: Use of MD5 for fingerprinting and potential timing attack vulnerabilities

4. Information Disclosure: Verbose error messages and debug logging may leak sensitive information

5. Insecure File Permissions: Private key files may be created with overly permissive access rights

Scope Note: This is a library codebase. The demo files are intended for educational purposes and should never be used in production. The core library code (paramiko/*) is the primary security concern.

---

Findings

Injection Vulnerabilities

Finding 1: Command Injection via ProxyCommand Configuration

Severity: Critical

Location: paramiko/config.py:161-164

Code Snippet:

# Special-case for noop ProxyCommands
elif key == "proxycommand" and value.lower() == "none":
    # Store 'none' as None - not as a string implying that the
    # proxycommand is the literal shell command "none"!
    context["config"][key] = None

Description: The SSH config parser accepts ProxyCommand directives that are later executed as shell commands. While there is special handling for "none", arbitrary commands from SSH config files are executed without sanitization. An attacker who can control the SSH config file (e.g., through a malicious repository's .ssh/config) can achieve arbitrary command execution.

Recommended Fix:

# Add validation and sanitization for ProxyCommand
elif key == "proxycommand":
    if value.lower() == "none":
        context["config"][key] = None
    else:
        # Validate command doesn't contain dangerous patterns
        dangerous_patterns = [';', '&&', '||', '`', '$', '|', '>', '<']
        if any(pattern in value for pattern in dangerous_patterns):
            raise ConfigParseError(f"ProxyCommand contains potentially dangerous characters: {value}")
        context["config"][key] = value

Finding 2: Command Injection via Match Exec Directive

Severity: Critical

Location: paramiko/config.py:69

Code Snippet:

# Doesn't seem worth making this 'special' for now, it will fit well
# enough (no actual match-exec config key to be confused with).
"match-exec": ["%C", "%d", "%h", "%L", "%l", "%n", "%p", "%r", "%u"],

Description: The SSH config parser supports "Match exec" directives that execute arbitrary shell commands to determine if a configuration block should be applied. This is executed during config parsing without any sanitization, allowing command injection if an attacker can control the SSH config file.

Recommended Fix:

# In the config parsing logic, add validation for match-exec
if key == "match" and "exec" in value.lower():
    # Log warning about dangerous directive
    logger.warning("Match exec directive found - this executes arbitrary commands")
    # Consider adding a configuration option to disable exec matching
    if not self.allow_exec_match:
        raise ConfigParseError("Match exec directives are disabled for security")

Finding 3: Shell Command Execution in Demo Forward Script

Severity: High

Location: demos/forward.py:56-102

Code Snippet:

def handle(self):
    try:
        chan = self.ssh_transport.open_channel(
            "direct-tcpip",
            (self.chain_host, self.chain_port),
            self.request.getpeername(),
        )
    except Exception as e:
        verbose(
            "Incoming request to %s:%d failed: %s"
            % (self.chain_host, self.chain_port, repr(e))
        )
        return

Description: The forward.py demo script forwards TCP connections without validating the destination host/port. While this is demo code, it demonstrates a pattern that could lead to SSRF (Server-Side Request Forgery) vulnerabilities if used as a template for production code.

Recommended Fix:

# Add whitelist validation for forwarding destinations
ALLOWED_FORWARD_HOSTS = ['localhost', '127.0.0.1']
ALLOWED_FORWARD_PORTS = [80, 443, 8080]

def handle(self):
    # Validate destination
    if self.chain_host not in ALLOWED_FORWARD_HOSTS:
        verbose(f"Forwarding to {self.chain_host} not allowed")
        return
    if self.chain_port not in ALLOWED_FORWARD_PORTS:
        verbose(f"Forwarding to port {self.chain_port} not allowed")
        return
    
    try:
        chan = self.ssh_transport.open_channel(
            "direct-tcpip",
            (self.chain_host, self.chain_port),
            self.request.getpeername(),
        )

Authentication & Authorization Issues

Finding 4: Hardcoded Credentials in Demo Server

Severity: High

Location: demos/demo_server.py:60-64

Code Snippet:

def check_auth_password(self, username, password):
    if (username == "robey") and (password == "foo"):
        return paramiko.AUTH_SUCCESSFUL
    return paramiko.AUTH_FAILED

Description: The demo server contains hardcoded username "robey" and password "foo". While this is demo code, it represents a dangerous pattern that developers might copy into production code. The credentials are trivially guessable.

Recommended Fix:

# Use environment variables or configuration files for credentials
import os
import hashlib

def check_auth_password(self, username, password):
    # Load expected credentials from environment
    expected_user = os.environ.get('SSH_USERNAME')
    expected_pass_hash = os.environ.get('SSH_PASSWORD_HASH')
    
    if not expected_user or not expected_pass_hash:
        # Fail closed if credentials not configured
        return paramiko.AUTH_FAILED
    
    # Use constant-time comparison
    password_hash = hashlib.sha256(password.encode()).hexdigest()
    if username == expected_user and password_hash == expected_pass_hash:
        return paramiko.AUTH_SUCCESSFUL
    return paramiko.AUTH_FAILED

Finding 5: Weak Public Key Authentication Check

Severity: Medium

Location: demos/demo_server.py:65-70

Code Snippet:

def check_auth_publickey(self, username, key):
    print("Auth attempt with key: " + u(hexlify(key.get_fingerprint())))
    if (username == "robey") and (key == self.good_pub_key):
        return paramiko.AUTH_SUCCESSFUL
    return paramiko.AUTH_FAILED

Description: The public key authentication only checks if the key matches a single hardcoded key. There's no validation of key type, strength, or revocation status. The comparison uses Python's default equality which may not be constant-time.

Recommended Fix:

def check_auth_publickey(self, username, key):
    # Validate key type is acceptable
    ALLOWED_KEY_TYPES = ['ssh-rsa', 'ssh-ed25519', 'ecdsa-sha2-nistp256']
    if key.get_name() not in ALLOWED_KEY_TYPES:
        return paramiko.AUTH_FAILED
    
    # Check minimum key strength
    if key.get_name() == 'ssh-rsa' and key.get_bits() < 2048:
        return paramiko.AUTH_FAILED
    
    # Load authorized keys from file
    authorized_keys = self._load_authorized_keys(username)
    
    # Use constant-time comparison
    for auth_key in authorized_keys:
        if constant_time_bytes_eq(key.asbytes(), auth_key.asbytes()):
            return paramiko.AUTH_SUCCESSFUL
    
    return paramiko.AUTH_FAILED

Finding 6: Missing Authentication Rate Limiting

Severity: Medium

Location: paramiko/auth_handler.py (general finding)

Code Snippet:

# No rate limiting code exists in the authentication handler
# Attackers can attempt unlimited authentication attempts

Description: The authentication handler does not implement rate limiting or account lockout mechanisms. This allows attackers to perform unlimited brute-force authentication attempts against SSH servers using Paramiko.

Recommended Fix:

# Add rate limiting to auth_handler.py
from collections import defaultdict
from time import time

class AuthHandler:
    # Track failed attempts per source IP
    _failed_attempts = defaultdict(list)
    MAX_ATTEMPTS = 5
    LOCKOUT_DURATION = 300  # 5 minutes
    
    def _check_rate_limit(self, source_ip):
        now = time()
        # Clean old attempts
        self._failed_attempts[source_ip] = [
            t for t in self._failed_attempts[source_ip]
            if now - t < self.LOCKOUT_DURATION
        ]
        
        if len(self._failed_attempts[source_ip]) >= self.MAX_ATTEMPTS:
            raise AuthenticationException(
                f"Too many failed attempts from {source_ip}"
            )
    
    def _record_failed_attempt(self, source_ip):
        self._failed_attempts[source_ip].append(time())

Data Exposure

Finding 7: Sensitive Data in Error Messages

Severity: Medium

Location: paramiko/pkey.py:547-549

Code Snippet:

def _got_bad_key_format_id(id_):
    raise SSHException(
        "not a valid {} private key file".format(id_)
    )

Description: Error messages reveal detailed information about key parsing failures, which could help attackers understand the key format being used and potentially aid in cryptographic attacks.

Recommended Fix:

def _got_bad_key_format_id(id_):
    # Log detailed error internally
    logger.debug(f"Invalid key format: {id_}")
    # Return generic error to user
    raise SSHException(
        "Invalid private key file format"
    )

Finding 8: Password Logging in Demo Scripts

Severity: High

Location: demos/demo.py:80-81

Code Snippet:

pw = getpass.getpass("Password for %s@%s: " % (username, hostname))
t.auth_password(username, pw)

Description: While getpass is used correctly to avoid echoing passwords, there's no guarantee that the password variable is properly cleared from memory after use. Additionally, if any exception occurs, the password might be included in stack traces.

Recommended Fix:

import sys

pw = getpass.getpass("Password for %s@%s: " % (username, hostname))
try:
    t.auth_password(username, pw)
finally:
    # Overwrite password in memory
    if 'pw' in locals():
        pw = None
        # Force garbage collection
        import gc
        gc.collect()

Finding 9: Verbose Debug Logging May Expose Secrets

Severity: Medium

Location: paramiko/packet.py:637-645

Code Snippet:

def _log(self, level, msg):
    if self.__logger is None:
        return
    self.__logger.log(level, msg)

def _check_keepalive(self):
    if (
        not self.__keepalive_interval
        or not self.__block_engine_out
        or self.__need_rekey
    ):

Description: The packet logger may log sensitive data including key material, authentication tokens, or encrypted payloads if debug logging is enabled. There's no filtering of sensitive data before logging.

Recommended Fix:

def _log(self, level, msg):
    if self.__logger is None:
        return
    
    # Filter sensitive data from log messages
    sensitive_patterns = [
        r'password[=:]\s*\S+',
        r'key[=:]\s*[A-Za-z0-9+/=]+',
        r'token[=:]\s*\S+',
    ]
    
    filtered_msg = msg
    for pattern in sensitive_patterns:
        filtered_msg = re.sub(pattern, '[REDACTED]', filtered_msg, flags=re.IGNORECASE)
    
    self.__logger.log(level, filtered_msg)

Finding 10: Hardcoded Test Private Keys in Repository

Severity: Medium

Location: demos/test_rsa.key, demos/user_rsa_key

Code Snippet:

# Private key files committed to repository
demos/test_rsa.key (883B)
demos/user_rsa_key (887B)

Description: Private SSH keys are committed directly to the repository. While these are test keys, this sets a dangerous precedent and the keys could be accidentally used in production or by developers who don't realize they're public.

Recommended Fix:

# Remove private keys from repository
git rm demos/test_rsa.key demos/user_rsa_key

# Add to .gitignore
echo "*.key" >> .gitignore
echo "!*.key.pub" >> .gitignore

# Generate keys at runtime in tests
# In test setup:
def generate_test_key():
    from paramiko import RSAKey
    key = RSAKey.generate(2048)
    return key

Dependency Vulnerabilities

Finding 11: Outdated Cryptography Dependency Minimum Version

Severity: High

Location: pyproject.toml:13

Code Snippet:

dependencies = [
  "bcrypt>=3.2",
  "cryptography>=3.3",
  "invoke>=2.0",
  "pynacl>=1.5",
]

Description: The minimum required version of cryptography is 3.3, which was released in 2020 and has known vulnerabilities. The cryptography library has had multiple security updates since version 3.3, including fixes for timing attacks and other cryptographic issues.

Recommended Fix:

dependencies = [
  "bcrypt>=4.0",
  "cryptography>=41.0",  # Latest stable with security fixes
  "invoke>=2.0",
  "pynacl>=1.5",
]

Finding 12: No Dependency Pinning for Security

Severity: Low

Location: pyproject.toml:11-16

Code Snippet:

dependencies = [
  "bcrypt>=3.2",
  "cryptography>=3.3",
  "invoke>=2.0",
  "pynacl>=1.5",
]

Description: Dependencies use minimum version constraints (>=) without upper bounds. This could allow automatic installation of versions with breaking changes or security vulnerabilities. While flexibility is important for a library, there should be known-good version ranges.

Recommended Fix:

dependencies = [
  "bcrypt>=4.0,<5.0",
  "cryptography>=41.0,<43.0",
  "invoke>=2.0,<3.0",
  "pynacl>=1.5,<2.0",
]

# Add a security policy document
# SECURITY.md should document:
# - Supported versions
# - Security update policy
# - How to report vulnerabilities

Configuration Issues

Finding 13: Use of MD5 for Key Fingerprinting

Severity: Medium

Location: paramiko/pkey.py:345-355

Code Snippet:

def get_fingerprint(self):
    """
    Return an MD5 fingerprint of the public key as a 16-byte `bytes`
    object. Nothing secret is revealed.

    .. note::
        This method uses the legacy MD5 fingerprint format. For
        security purposes, you should prefer the SHA256 fingerprint
        format available via the `.fingerprint` property.

    :return: a 16-byte `bytes` object containing the MD5 fingerprint.
    """
    return md5(self.asbytes()).digest()

Description: While the code includes a deprecation notice, MD5 is still used for key fingerprinting. MD5 is cryptographically broken and should not be used even for non-secret purposes, as collision attacks could allow key substitution.

Recommended Fix:

def get_fingerprint(self):
    """
    Return a SHA256 fingerprint of the public key.
    
    .. deprecated:: 3.0
        Use the `.fingerprint` property instead which returns SHA256.
    
    :return: SHA256 fingerprint bytes
    """
    import warnings
    warnings.warn(
        "get_fingerprint() is deprecated, use .fingerprint property",
        DeprecationWarning,
        stacklevel=2
    )
    # Return SHA256 instead of MD5
    return sha256(self.asbytes()).digest()

Finding 14: Weak Default Cipher Preferences

Severity: Medium

Location: paramiko/transport.py (general finding based on SSH protocol implementation)

Code Snippet:

# No explicit code snippet as this is about default cipher ordering
# The transport layer should prefer strong ciphers by default

Description: The SSH transport implementation should explicitly prefer strong, modern ciphers and key exchange algorithms. Weak algorithms like 3DES, RC4, or SHA-1 based MACs should be disabled by default.

Recommended Fix:

# In transport.py, define secure defaults
PREFERRED_CIPHERS = [
    'aes256-gcm@openssh.com',
    'aes128-gcm@openssh.com',
    'aes256-ctr',
    'aes192-ctr',
    'aes128-ctr',
]

PREFERRED_KEXS = [
    'curve25519-sha256',
    'curve25519-sha256@libssh.org',
    'ecdh-sha2-nistp521',
    'ecdh-sha2-nistp384',
    'ecdh-sha2-nistp256',
    'diffie-hellman-group-exchange-sha256',
]

PREFERRED_MACS = [
    'hmac-sha2-512-etm@openssh.com',
    'hmac-sha2-256-etm@openssh.com',
    'hmac-sha2-512',
    'hmac-sha2-256',
]

# Explicitly disable weak algorithms
DISABLED_ALGORITHMS = [
    '3des-cbc',
    'arcfour',
    'arcfour128',
    'arcfour256',
    'hmac-sha1',
    'hmac-md5',
]

Finding 15: Insecure File Permissions on Private Key Creation

Severity: High

Location: paramiko/pkey.py:738-772

Code Snippet:

def _write_private_key_file(filename, key, format, password):
    """
    Write private key contents to a file.
    """
    with open(filename, "w") as f:
        os.chmod(filename, o600)
        _write_private_key(f, key, format, password)

Description: The file is opened before setting permissions to 0600. This creates a race condition where the file briefly exists with default permissions (potentially world-readable) before being secured. An attacker monitoring the filesystem could read the private key during this window.

Recommended Fix:

def _write_private_key_file(filename, key, format, password):
    """
    Write private key contents to a file with secure permissions.
    """
    # Create file descriptor with secure permissions atomically
    import os
    import stat
    
    # Use os.open with explicit mode to set permissions atomically
    fd = os.open(
        filename,
        os.O_WRONLY | os.O_CREAT | os.O_EXCL,  # Fail if exists
        stat.S_IRUSR | stat.S_IWUSR  # 0600
    )
    
    try:
        with os.fdopen(fd, 'w') as f:
            _write_private_key(f, key, format, password)
    except:
        # Clean up on error
        os.close(fd)
        os.unlink(filename)
        raise

Finding 16: Potential Timing Attack in Key Comparison

Severity: Medium

Location: paramiko/pkey.py:287-289

Code Snippet:

def __eq__(self, other):
    return self.asbytes() == other.asbytes()

Description: Key comparison uses Python's default equality operator which performs byte-by-byte comparison and returns early on the first mismatch. This creates a timing side-channel that could theoretically be exploited to determine key material through timing analysis.

Recommended Fix:

def __eq__(self, other):
    from paramiko.util import constant_time_bytes_eq
    
    if not isinstance(other, PKey):
        return False
    
    # Use constant-time comparison
    return constant_time_bytes_eq(
        self.asbytes(),
        other.asbytes()
    )

Finding 17: Missing Security Headers in SFTP Implementation

Severity: Low

Location: paramiko/sftp_server.py (general finding)

Code Snippet:

# No explicit security controls in SFTP server implementation
# Should validate paths, check permissions, prevent directory traversal

Description: The SFTP server implementation should include explicit security controls such as path validation, chroot enforcement, and prevention of directory traversal attacks. While some validation exists, it should be more comprehensive and documented.

Recommended Fix:

# In sftp_server.py, add comprehensive path validation
import os.path

class SFTPServer:
    def __init__(self, channel, name, server, sftp_si, *largs, **kwargs):
        # Set chroot directory
        self.chroot = kwargs.get('chroot', None)
        super().__init__(channel, name, server, sftp_si, *largs, **kwargs)
    
    def _validate_path(self, path):
        """Validate and normalize path to prevent traversal attacks."""
        # Normalize path
        normalized = os.path.normpath(path)
        
        # Prevent directory traversal
        if normalized.startswith('..'):
            raise SFTPError(SFTP_PERMISSION_DENIED, "Path traversal not allowed")
        
        # Enforce chroot if configured
        if self.chroot:
            full_path = os.path.join(self.chroot, normalized.lstrip('/'))
            if not full_path.startswith(self.chroot):
                raise SFTPError(SFTP_PERMISSION_DENIED, "Access outside chroot denied")
            return full_path
        
        return normalized

Finding 18: No Certificate Validation in Host Key Checking

Severity: Medium

Location: paramiko/client.py (general finding based on host key handling)

Code Snippet:

# Host key checking exists but doesn't validate certificates
# Should check certificate validity, expiration, revocation

Description: While Paramiko supports SSH certificates, there's no automatic validation of certificate validity periods, revocation status, or certificate authority trust chains. This could allow use of expired or revoked certificates.

Recommended Fix:

# In client.py, add certificate validation
def _validate_host_certificate(self, hostname, cert):
    """Validate SSH certificate."""
    from datetime import datetime
    
    # Check certificate type
    if not hasattr(cert, 'certificate'):
        return True  # Not a certificate, skip validation
    
    # Check validity period
    now = datetime.utcnow()
    if cert.certificate.valid_after > now:
        raise BadHostKeyException(
            hostname,
            cert,
            "Certificate not yet valid"
        )
    if cert.certificate.valid_before < now:
        raise BadHostKeyException(
            hostname,
            cert,
            "Certificate has expired"
        )
    
    # Check certificate principals match hostname
    if hostname not in cert.certificate.principals:
        raise BadHostKeyException(
            hostname,
            cert,
            "Certificate principals don't match hostname"
        )
    
    # TODO: Check certificate revocation list (CRL)
    # TODO: Validate CA signature
    
    return True

---

Risk Matrix

SeverityCount
Critical2
High4
Medium9
Low2
Informational1
TOTAL18

---

Remediation Roadmap

Immediate Actions (Critical/High Priority)

1. Command Injection Fixes (Critical - Findings 1, 2)

2. Remove Hardcoded Credentials (High - Finding 4)

3. Update Cryptography Dependencies (High - Finding 11)

4. Fix File Permission Race Condition (High - Finding 15)

Short-term Actions (Medium Priority)

5. Deprecate MD5 Fingerprinting (Medium - Finding 13)

6. Implement Authentication Rate Limiting (Medium - Finding 6)

7. Enhance Error Message Security (Medium - Findings 7, 9)

8. Add Constant-Time Comparisons (Medium - Finding 16)

Long-term Actions (Low Priority / Enhancements)

9. Strengthen Default Cipher Configuration (Medium - Finding 14)

10. Implement Certificate Validation (Medium - Finding 18)

11. SFTP Security Enhancements (Low - Finding 17)

12. Dependency Version Pinning (Low - Finding 12)

Continuous Improvements

13. Security Testing

14. Documentation

15. Code Review Process

Notes

Priority Legend: