{"title":"Import Hijacking via Malicious sys.path Manipulation","language":"Python","severity":"Critical","cwe":"CWE-706","source_lines":[4],"flow_lines":[4,5,7],"sink_lines":[5],"vulnerable_code":"import sys\nimport importlib\ndef load_sensor_plugin(plugin_dir, plugin_name):\n    usr_plugin_path = plugin_dir.strip()\n    sys.path.insert(0, usr_plugin_path)\n    try:\n        sensor_mod = importlib.import_module(plugin_name)\n        handler = getattr(sensor_mod, 'run_sensor')\n        return handler()\n    except (ImportError, AttributeError) as ex:\n        return {'error': str(ex)}\n    finally:\n        pass","explanation":"The function accepts `plugin_dir` from user input and, after a trivial `.strip()` call, inserts it at position 0 of `sys.path` with the highest priority. This allows an attacker to supply a malicious directory (e.g., `/tmp/evil`) that shadows legitimate standard-library or third-party modules. When `importlib.import_module(plugin_name)` is called on line 7, Python resolves the module from the attacker-controlled path first, loading arbitrary attacker-supplied code and achieving remote code execution. The empty `finally` block ensures the poisoned path entry persists for all subsequent imports in the process.","remediation":"The fix addresses the vulnerability through multiple layers of defense: (1) plugin_dir is canonicalized via os.path.realpath and checked against an explicit allowlist of permitted directories, preventing arbitrary path injection; (2) plugin_name is validated against a strict regex pattern and a blocklist of critical stdlib module names to prevent module shadowing; (3) the directory is appended to sys.path (instead of inserted at position 0) so standard library modules always take precedence; (4) after import, the module's __file__ is verified to confirm it actually resides within the allowed directory; (5) the finally block now properly removes the added path entry to prevent persistent path poisoning across subsequent imports.","secure_code":"import sys\nimport importlib\nimport os\nimport re\n\n# Allowlist of permitted plugin directories (configure per deployment)\nALLOWED_PLUGIN_DIRS = [\n    '/opt/iot-platform/plugins',\n    '/usr/local/share/sensor-plugins',\n]\n\n# Allowlist pattern for valid plugin names (alphanumeric + underscores only)\n_VALID_PLUGIN_NAME = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')\n\n# Set of standard-library / critical module names that must never be shadowed\n_BLOCKED_MODULE_NAMES = frozenset({\n    'os', 'sys', 'json', 'subprocess', 'importlib', 'builtins', 'io',\n    'socket', 'http', 'shutil', 'pathlib', 'ctypes', 'code', 'pickle',\n    'marshal', 'compileall', 'runpy', 'xmlrpc', 'multiprocessing',\n})\n\n\ndef load_sensor_plugin(plugin_dir, plugin_name):\n    # --- Validate plugin_name ---\n    if not isinstance(plugin_name, str) or not _VALID_PLUGIN_NAME.match(plugin_name):\n        return {'error': 'Invalid plugin name. Only alphanumeric characters and underscores are allowed.'}\n\n    if plugin_name in _BLOCKED_MODULE_NAMES:\n        return {'error': f'Plugin name \"{plugin_name}\" conflicts with a protected module and cannot be loaded.'}\n\n    # --- Validate and canonicalize plugin_dir ---\n    if not isinstance(plugin_dir, str):\n        return {'error': 'plugin_dir must be a string.'}\n\n    resolved_dir = os.path.realpath(os.path.normpath(plugin_dir.strip()))\n\n    # Check against the allowlist of permitted directories\n    if not any(resolved_dir == os.path.realpath(allowed) or\n               resolved_dir.startswith(os.path.realpath(allowed) + os.sep)\n               for allowed in ALLOWED_PLUGIN_DIRS):\n        return {'error': f'Plugin directory \"{resolved_dir}\" is not in the list of allowed plugin directories.'}\n\n    if not os.path.isdir(resolved_dir):\n        return {'error': f'Plugin directory \"{resolved_dir}\" does not exist or is not a directory.'}\n\n    # --- Safely modify sys.path and import ---\n    # Append (not insert at 0) so that stdlib/site-packages are still resolved first,\n    # preventing module shadowing even if validation is somehow bypassed.\n    path_added = False\n    if resolved_dir not in sys.path:\n        sys.path.append(resolved_dir)\n        path_added = True\n\n    try:\n        sensor_mod = importlib.import_module(plugin_name)\n\n        # Verify the loaded module actually resides under the allowed directory\n        mod_file = getattr(sensor_mod, '__file__', None)\n        if mod_file is None:\n            return {'error': 'Loaded module has no __file__ attribute; cannot verify origin.'}\n\n        mod_real_path = os.path.realpath(mod_file)\n        if not mod_real_path.startswith(resolved_dir + os.sep):\n            return {'error': 'Loaded module does not originate from the specified plugin directory.'}\n\n        if not hasattr(sensor_mod, 'run_sensor') or not callable(sensor_mod.run_sensor):\n            return {'error': f'Plugin \"{plugin_name}\" does not expose a callable \"run_sensor\" function.'}\n\n        handler = sensor_mod.run_sensor\n        return handler()\n    except (ImportError, AttributeError) as ex:\n        return {'error': str(ex)}\n    finally:\n        # Clean up: remove the path entry so it does not persist and affect future imports\n        if path_added and resolved_dir in sys.path:\n            sys.path.remove(resolved_dir)"}